diff --git a/.gitattributes b/.gitattributes index 705bd8f43e00..00b0e5eccb98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt * text=auto eol=lf +# Batch files need CRLF for cmd.exe to parse them reliably. +*.cmd text eol=crlf +*.bat text eol=crlf diff --git a/.github/actions/setup-apt-mirrors/action.yml b/.github/actions/setup-apt-mirrors/action.yml new file mode 100644 index 000000000000..5beff201d188 --- /dev/null +++ b/.github/actions/setup-apt-mirrors/action.yml @@ -0,0 +1,22 @@ +name: Setup APT mirrors +description: Configure Ubuntu package downloads with automatic mirror failover. +runs: + using: composite + steps: + - shell: bash + run: | + # Replace the existing Blacksmith mirror list as well as direct sources. + printf '%s\tpriority:%s\n' \ + https://archive.ubuntu.com/ubuntu 1 \ + https://mirrors.edge.kernel.org/ubuntu 2 \ + https://mirror.math.princeton.edu/pub/ubuntu 3 \ + | sudo tee /etc/apt/blacksmith-ubuntu-mirrors.txt > /dev/null + + # APT's mirror transport retries each file against the next server. + sudo find /etc/apt -maxdepth 2 -type f \( -name '*.list' -o -name '*.sources' \) \ + -exec sed -i -E \ + 's#https?://(([^/]+\.)?archive|security)\.ubuntu\.com/ubuntu/?#mirror+file:/etc/apt/blacksmith-ubuntu-mirrors.txt#g' {} + + + # Move on to a fallback before an unreachable server exhausts the job. + printf '%s\n' 'Acquire::http::Timeout "15";' 'Acquire::https::Timeout "15";' \ + | sudo tee /etc/apt/apt.conf.d/80-mirror-timeouts > /dev/null diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d4bf10c050e..22947b977491 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,8 +104,12 @@ jobs: - name: Typecheck run: vpr typecheck + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Build desktop pipeline run: vp run build:desktop @@ -152,8 +156,12 @@ jobs: continue-on-error: true run: vp run --filter @t3tools/desktop ensure:electron + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + 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 bc52f977a058..e35c99aaa7a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,6 +237,8 @@ jobs: - name: Typecheck run: vp run typecheck + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config @@ -568,6 +570,9 @@ jobs: exit $code } + - uses: ./.github/actions/setup-apt-mirrors + if: matrix.platform == 'linux' + - name: Install Linux desktop build libraries if: matrix.platform == 'linux' shell: bash diff --git a/AGENTS.md b/AGENTS.md index d72f86069e5e..669a15a11b73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,13 +89,9 @@ Long-term maintainability is a core priority. If you add new functionality, firs ### Provider Adapter Pattern -All providers implement a unified adapter interface (`ProviderAdapterShape`) in `apps/server/src/provider/Services/`. Each adapter declares: +All providers implement `ProviderAdapterShape` in `apps/server/src/provider/Services/ProviderAdapter.ts`. Capabilities declare `sessionModelSwitch` (`"in-session"` or `"unsupported"`), optional `promptlessTurnContinuation`, and optional `supportsConversationRollback`. Adapters without native history rewind must explicitly set `supportsConversationRollback: false` so checkpoints reject rewind before restoring files. -- `transport` — how it communicates (`app-server-json-rpc`, `sdk-cli-server`, `acp-stdio`, `http-sse`, `cli-headless-json`, `cli-persistent-json`, `sdk-query`) -- `sessionModelSwitch` — `"in-session"`, `"restart-session"`, or `"unsupported"` -- `modelDiscovery` — `"native"`, `"acp-or-config"`, `"config-or-static"`, `"session-native"`, or `"unsupported"` - -Adapters are registered in `provider/Layers/ProviderAdapterRegistry.ts` and looked up by provider kind at runtime. Complex providers have dedicated process managers (e.g. `codexAppServerManager.ts`, `geminiCliServerManager.ts`, `ampServerManager.ts`). +`provider/builtInDrivers.ts` registers built-in drivers; the adapter registry resolves providers and their configured instances. Transport and model discovery are driver implementation details, not adapter capability fields. Complex providers have dedicated process managers, while ACP providers share the runtime in `provider/acp/`. ### Key Server Modules @@ -164,3 +160,15 @@ agents. examples of idiomatic usage, tests, module structure, and API design. - When writing relay infrastructure code with Alchemy, inspect `.repos/alchemy-effect/` for examples of idiomatic usage, tests, module structure, and API design. + +## Documentation + +Most code changes do not need an internal documentation change. Agents can read the code. + +- `docs/internals/` is for architectural decisions and their reasons, constraints that span components, and implementation traps that are hard to discover from the source. Before adding a paragraph, ask what a maintainer would get wrong without it. If reading the relevant code answers the question, leave it out. +- Do not document every feature, enumerate fields or methods, narrate control flow, maintain file catalogs, or append PR summaries. Types, tests, and code already record the implementation. The glossary defines shared vocabulary; it is not a feature index. +- Keep a local implementation explanation in a nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. +- When a documented decision or constraint changes, rewrite or remove the affected text. Do not append another account of the new behavior. A new internal page needs a distinct, durable reason to exist. +- `docs/user/` helps users accomplish tasks. Give each major feature a concise section explaining what it does, how to start, and anything unintuitive. A settings path is useful; descriptions of visible buttons, icons, layouts, animations, or every UI state are not. Before adding text, ask what task or decision it helps the user with. +- Keep user docs in the shipped product's voice, without implementation details or contributor tooling. Update the relevant feature section when how to use it changes. A UI tweak does not need a documentation entry, and a new control does not need its own page. +- `docs/operations/` holds maintainer setup, release, and debugging procedures. Keep instructions for operating an installed T3 Code server in the user guides. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd14d4b951c2..dad7d8294ade 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ First off, thank you for considering contributing to this fork! It's people like ## Developer Setup -See the [maintainer scripts guide](docs/internals/scripts.md#first-checkout) for the initial checkout, +See the [maintainer scripts guide](docs/operations/development.md#first-checkout) for the initial checkout, development commands, tests, and platform-specific desktop packaging prerequisites. This fork is maintained in [aaditagrawal/t3code](https://github.com/aaditagrawal/t3code) and focuses on expanding provider support, preserving usage and limit monitoring, and improving the core orchestration and persistence layers. diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 07fb87b051f1..496a8a27a7b6 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -165,10 +165,14 @@ function registerMacLauncherBundle(appBundlePath) { } } +// Bundle-internal paths are macOS paths whatever host builds them. export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) { return { sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath, - generatedIconPath: NodePath.join(runtimeDir, development ? "icon-dev.icns" : "icon-prod.icns"), + generatedIconPath: NodePath.posix.join( + runtimeDir, + development ? "icon-dev.icns" : "icon-prod.icns", + ), }; } @@ -280,12 +284,12 @@ function readJson(path) { } export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) { - const executableDir = NodePath.join(appBundlePath, "Contents", "MacOS"); + const executableDir = NodePath.posix.join(appBundlePath, "Contents", "MacOS"); const launcherExecutableName = `${displayName} Launcher`; return { launcherExecutableName, - launcherBinaryPath: NodePath.join(executableDir, launcherExecutableName), - runtimeElectronBinaryPath: NodePath.join(executableDir, "Electron"), + launcherBinaryPath: NodePath.posix.join(executableDir, launcherExecutableName), + runtimeElectronBinaryPath: NodePath.posix.join(executableDir, "Electron"), }; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 1ed5a1b8ebf9..9d2a907c73d6 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -84,9 +84,10 @@ describe("electron development launcher", () => { const development = resolveMacLauncherIconPaths("/runtime", true); const production = resolveMacLauncherIconPaths("/runtime", false); - assert.match(development.sourceIconPath, /assets\/dev\/blueprint-macos-1024\.png$/); + // The source icons are real repo paths, joined for the host. + assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/); assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns"); - assert.match(production.sourceIconPath, /assets\/prod\/black-macos-1024\.png$/); + assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/); assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns"); }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 5c39ff304b3b..71bcf5f7aef1 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -88,6 +89,7 @@ const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodePath.layerPosix, DesktopConfig.layerTest({ ...env, }), diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts index bb118d43d29a..78819d06e6cc 100644 --- a/apps/desktop/src/app/DesktopAssets.test.ts +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -20,7 +21,11 @@ const environmentLayer = DesktopEnvironment.layer({ isPackaged: true, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, -}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))); +}).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest({})), + ), +); describe("DesktopAssets", () => { it.effect("uses canonical source-tree icons for unpackaged development", () => @@ -39,6 +44,7 @@ describe("DesktopAssets", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodePath.layerPosix, DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }), ), ), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index c58830b30a7f..aa28ff8d86eb 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -4,6 +4,7 @@ import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; @@ -226,10 +227,11 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces malformed catalog documents without deleting them", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(catalogPath, "{not-json"); @@ -247,6 +249,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces catalog filesystem failures instead of treating them as missing", () => Effect.gen(function* () { + const path = yield* Path.Path; const baseFileSystem = yield* FileSystem.FileSystem; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", @@ -255,7 +258,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "readFileString", - pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`, + pathOrDescriptor: path.join(baseDir, "userdata", "connection-catalog.json"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -272,11 +275,11 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Failed to read the desktop connection catalog at ${baseDir}/userdata/connection-catalog.json.`, + `Failed to read the desktop connection catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), @@ -285,6 +288,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports the failed catalog write operation and path", () => Effect.gen(function* () { const baseFileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", }); @@ -292,7 +296,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "makeDirectory", - pathOrDescriptor: `${baseDir}/userdata`, + pathOrDescriptor: path.join(baseDir, "userdata"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -310,11 +314,11 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, ); assert.equal(error.operation, "create-directory"); - assert.equal(error.path, `${baseDir}/userdata`); + assert.equal(error.path, path.join(baseDir, "userdata")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Desktop connection catalog write failed during create-directory at ${baseDir}/userdata.`, + `Desktop connection catalog write failed during create-directory at ${path.join(baseDir, "userdata")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), @@ -323,6 +327,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports the legacy migration stage", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; @@ -335,7 +340,7 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError, ); assert.equal(error.operation, "read-legacy-registry"); - assert.equal(error.catalogPath, `${environment.stateDir}/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(environment.stateDir, "connection-catalog.json")); assert.instanceOf( error.cause, DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, @@ -345,7 +350,7 @@ describe("DesktopConnectionCatalogStore", () => { assert.exists(registryError.cause); assert.equal( error.message, - `Legacy desktop saved-environment migration failed during read-legacy-registry into ${environment.stateDir}/connection-catalog.json.`, + `Legacy desktop saved-environment migration failed during read-legacy-registry into ${path.join(environment.stateDir, "connection-catalog.json")}.`, ); assert.notEqual(error.message, registryError.message); }), @@ -355,10 +360,11 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports invalid encrypted catalog data without exposing it", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n'); @@ -381,6 +387,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => Effect.gen(function* () { + const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", @@ -399,14 +406,14 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, ); assert.equal(error.operation, "decrypt-catalog"); - assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json")); assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError); const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError; assert.instanceOf(decryptError.cause, Error); assert.equal(decryptError.cause.message, "invalid encrypted catalog"); assert.equal( error.message, - `Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`, + `Desktop connection catalog protection failed during decrypt-catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, ); assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 218e2c3e4ba2..89cc592831a7 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -26,7 +27,11 @@ const makeEnvironmentLayer = ( DesktopEnvironment.layer({ ...defaultInput, ...overrides, - }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env)))); + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest(env)), + ), + ); const makeEnvironment = ( overrides: Partial = {}, diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..17f3e06039b6 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -36,6 +36,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens the Full Disk Access settings anchor", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openSystemSettings("full-disk-access"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens remote SSH editor URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index b74e621bd583..cfa672914936 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,4 +1,8 @@ -import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; +import { + REMOTE_CAPABLE_EDITOR_IDS, + remoteSchemeForEditor, + type SystemSettingsPane, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +10,20 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; +/** + * Deep links to individual System Settings panes. These are app-fixed, not + * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep + * arbitrary link schemes from reaching the OS handler — and open through their + * own path below. The pane rather than the URL crosses the IPC boundary, so a + * renderer can only ask for one of these known destinations. + * + * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor. + */ +const SYSTEM_SETTINGS_URLS: Record = { + "full-disk-access": + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", +}; + // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) // must reach the OS handler; every other non-web scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); @@ -42,6 +60,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { export interface ElectronShellShape { readonly openExternal: (rawUrl: unknown) => Effect.Effect; readonly openPath: (path: string) => Effect.Effect; + readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } @@ -62,6 +81,13 @@ export const make = ElectronShell.of({ ), }), openPath: (path) => Effect.promise(() => Electron.shell.openPath(path)).pipe(Effect.asVoid), + openSystemSettings: (pane) => + Effect.promise(() => + Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( + () => true, + () => false, + ), + ), copyText: (text) => Effect.sync(() => { Electron.clipboard.writeText(text); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 39be03c95897..33f9c82814b9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -41,6 +41,7 @@ import { listLogFiles, openLogDir, openExternal, + openSystemSettings, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -102,6 +103,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(listLogFiles); yield* ipc.handle(readLogFile); yield* ipc.handle(openLogDir); + yield* ipc.handle(openSystemSettings); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 22a00c103d34..f3bba0d45a1c 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 63e1068c55c5..d91b43be213a 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -9,6 +9,7 @@ import { PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, + SystemSettingsPaneSchema, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; @@ -400,6 +401,16 @@ export const openLogDir = DesktopIpc.makeIpcMethod({ }), }); +export const openSystemSettings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, + payload: SystemSettingsPaneSchema, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) { + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openSystemSettings(pane); + }), +}); + export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 5331fffdf922..8e41710a350a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -120,6 +120,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { listLogFiles: () => ipcRenderer.invoke(IpcChannels.LOG_LIST_CHANNEL), readLogFile: (filename) => ipcRenderer.invoke(IpcChannels.LOG_READ_CHANNEL, filename), openLogDir: () => ipcRenderer.invoke(IpcChannels.LOG_OPEN_DIR_CHANNEL), + openSystemSettings: (pane: string) => + ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 9b0a652f09f1..003461085376 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -14,6 +14,7 @@ import * as Ref from "effect/Ref"; import * as BrowserSession from "../BrowserSession.ts"; import * as BrowserImport from "./BrowserImport.ts"; import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -105,28 +106,30 @@ describe("BrowserImport.importCookies", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.effect("refuses to import while the source browser holds its profile", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const { importer, root } = yield* withImporter(); - // The lock Chromium leaves while it is running, dangling target and - // all. This must stop the import before it ever asks the keychain. - yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); - - const error = yield* importer - .importCookies({ - input: { - sourceId: "helium", - sourceProfileDirectory: "Default", - targetProfileId: "default", - }, - scope: "persist:t3code-preview-test", - persistent: true, - }) - .pipe(Effect.flip); + it.effect.skipIf(!symlinksSupported)( + "refuses to import while the source browser holds its profile", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { importer, root } = yield* withImporter(); + // The lock Chromium leaves while it is running, dangling target and + // all. This must stop the import before it ever asks the keychain. + yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); + + const error = yield* importer + .importCookies({ + input: { + sourceId: "helium", + sourceProfileDirectory: "Default", + targetProfileId: "default", + }, + scope: "persist:t3code-preview-test", + persistent: true, + }) + .pipe(Effect.flip); - assert.equal(error.reason, "browserRunning"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + assert.equal(error.reason, "browserRunning"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e92f2f05e05c..386b3ef6f813 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -27,6 +27,7 @@ import * as BrowserSession from "../BrowserSession.ts"; import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; import type { CookieReadResult } from "./CookieDatabase.ts"; import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; +import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts"; import { BROWSER_IMPORT_SOURCES, resolveCookieDatabase, @@ -92,6 +93,15 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; if (yield* isSourceRunning(definition, context)) return "browserRunning"; + // Safari's jar is found by `stat`, which TCC permits without Full Disk + // Access — so a Safari that lists as ready may still refuse the read. Probe + // the grant here, so the wizard can open on the permission step and a + // post-grant recheck can tell granted from still-denied, rather than only + // discovering it by attempting the import. + if (definition.engine === "safari") { + const jar = yield* resolveCookieDatabase(definition, context, "."); + if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess"; + } return undefined; }); @@ -254,25 +264,29 @@ export const make = Effect.gen(function* BrowserImportMake() { const userDataDirectory = definition.userDataDirectory(pathContext); const read: Effect.Effect< CookieReadResult, - ChromiumCookieReadError | FirefoxCookieReadError, + ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > = - definition.engine === "firefox" - ? readFirefoxCookies(databasePath).pipe( + definition.engine === "safari" + ? readSafariCookies(databasePath).pipe( Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), ) - : readChromiumCookies({ - cookieDatabasePath: databasePath, - keychainService: definition.keychainService, - keychainAccount: definition.keychainAccount, - linuxSecretApplication: definition.linuxSecretApplication, - ...(platform === "win32" && userDataDirectory !== undefined - ? { - windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), - } - : {}), - platform, - }); + : definition.engine === "firefox" + ? readFirefoxCookies(databasePath).pipe( + Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), + ) + : readChromiumCookies({ + cookieDatabasePath: databasePath, + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, + linuxSecretApplication: definition.linuxSecretApplication, + ...(platform === "win32" && userDataDirectory !== undefined + ? { + windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), + } + : {}), + platform, + }); const result = yield* read.pipe( Effect.scoped, @@ -289,6 +303,12 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), ), + // Safari's reasons are already user-facing: a TCC refusal is the Full + // Disk Access prompt, anything else is a read failure. + SafariCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), }), ); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts new file mode 100644 index 000000000000..f5d07f765943 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -0,0 +1,443 @@ +// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar +// format byte by byte. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; + +import { + isPermissionDenied, + parseBinaryCookies, + readSafariCookies, + safariAccessDenied, + SafariCookieReadError, +} from "./SafariCookies.ts"; + +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +interface FixtureCookie { + readonly domain: string; + readonly name: string; + readonly path: string; + readonly value: string; + readonly flags: number; + /** Seconds since 2001-01-01, as Safari stores them. */ + readonly expiry: number; +} + +/** Encodes one cookie exactly as Safari lays it out. */ +function encodeCookie(cookie: FixtureCookie): Buffer { + const strings = [cookie.domain, cookie.name, cookie.path, cookie.value]; + const headerSize = 56; + const offsets: number[] = []; + let cursor = headerSize; + for (const value of strings) { + offsets.push(cursor); + cursor += Buffer.byteLength(value) + 1; + } + const size = cursor; + + const buffer = Buffer.alloc(size); + buffer.writeUInt32LE(size, 0); + buffer.writeUInt32LE(0, 4); + buffer.writeUInt32LE(cookie.flags, 8); + buffer.writeUInt32LE(0, 12); + buffer.writeUInt32LE(offsets[0]!, 16); + buffer.writeUInt32LE(offsets[1]!, 20); + buffer.writeUInt32LE(offsets[2]!, 24); + buffer.writeUInt32LE(offsets[3]!, 28); + buffer.writeUInt32LE(0, 32); + buffer.writeUInt32LE(0, 36); + buffer.writeDoubleLE(cookie.expiry, 40); + buffer.writeDoubleLE(0, 48); + strings.forEach((value, index) => { + buffer.write(value, offsets[index]!, "utf8"); + }); + return buffer; +} + +/** Builds a single-page `Cookies.binarycookies` file. */ +function encodeBinaryCookies(cookies: ReadonlyArray): Buffer { + const encoded = cookies.map(encodeCookie); + const headerSize = 12 + encoded.length * 4; + const offsets: number[] = []; + let cursor = headerSize; + for (const cookie of encoded) { + offsets.push(cursor); + cursor += cookie.length; + } + + const page = Buffer.alloc(cursor); + page.writeUInt32BE(0x0000_0100, 0); + page.writeUInt32LE(encoded.length, 4); + offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4)); + encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!)); + + const header = Buffer.alloc(8 + 4); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(1, 4); + header.writeUInt32BE(page.length, 8); + return Buffer.concat([header, page]); +} + +describe("parseBinaryCookies", () => { + it("reads Safari's format and rebases its 2001 epoch", () => { + const file = encodeBinaryCookies([ + { + domain: ".apple.com", + name: "session", + path: "/", + value: "abc", + // secure | httpOnly + flags: 0x1 | 0x4, + expiry: 800_000_000, + }, + { + domain: "example.test", + name: "plain", + path: "/app", + value: "v", + flags: 0, + expiry: 0, + }, + ]); + + expect(parseBinaryCookies(file)).toEqual([ + { + url: "https://apple.com/", + name: "session", + value: "abc", + domain: ".apple.com", + path: "/", + secure: true, + httpOnly: true, + // Safari counts from 2001-01-01, Electron from 1970. + expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS, + // The format predates SameSite; Lax is the safe modern default. + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + // Host-only: no leading dot in the jar, so no `domain` for Electron, + // which would otherwise re-add the dot and widen it to subdomains. + domain: undefined, + path: "/app", + secure: false, + httpOnly: false, + expirationDate: undefined, + sameSite: "lax", + }, + ]); + }); + + it("keeps __Host- cookies host-only so Electron accepts them", () => { + const file = encodeBinaryCookies([ + { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 }, + ]); + + expect(parseBinaryCookies(file)[0]).toMatchObject({ + url: "https://example.test/", + name: "__Host-id", + domain: undefined, + }); + }); + + it("brackets IPv6 hosts in the cookie URL", () => { + const file = encodeBinaryCookies([ + { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + + expect(parseBinaryCookies(file)[0]).toMatchObject({ + url: "http://[::1]/", + domain: undefined, + }); + }); + + it("reads cookies spread across multiple pages", () => { + // Safari pages its cookie file, and a single-page reader would silently + // return only the first slice. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + // Splice the two single-page files into one two-page file. + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + header.writeUInt32BE(firstPage.length, 8); + header.writeUInt32BE(secondPage.length, 12); + + const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage])); + + expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]); + }); + + it("rejects a page that runs past the end of the file", () => { + // `Buffer.subarray` clamps rather than throwing, so an overlong first page + // swallows the second one's bytes and advances the cursor past the end. + // Every cookie after the boundary then vanishes from a "successful" import. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + // Declares more bytes for page one than the file holds in total. + header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8); + header.writeUInt32BE(secondPage.length, 12); + + expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow( + SafariCookieReadError, + ); + }); + + it("rejects a record whose declared size runs past its page", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + // The record's own length is what bounds its string offsets; an inflated + // one lets them read the following record's bytes as this cookie's value. + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(0xffff, recordStart); + + expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError); + }); + + it("rejects records truncated inside the 56-byte header", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + + for (let size = 48; size < 56; size += 1) { + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(size, recordStart); + expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow( + SafariCookieReadError, + ); + } + }); + + it("rejects record offsets that point into the page header or an earlier record", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const firstRecord = valid.readUInt32LE(pageStart + 8); + + // Pointing the second offset at the page's offset table would let those + // table bytes parse as a fabricated record. + const intoTable = Buffer.from(valid); + intoTable.writeUInt32LE(4, pageStart + 12); + expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError); + + // Pointing it back at the first record makes the same bytes count twice. + const overlapping = Buffer.from(valid); + overlapping.writeUInt32LE(firstRecord, pageStart + 12); + expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError); + + // And a well-formed two-record page still parses. + expect(parseBinaryCookies(valid)).toHaveLength(2); + }); + + it("rejects string offsets that point into the record header", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + + for (const offsetField of [16, 20, 24, 28]) { + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(55, recordStart + offsetField); + expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow( + SafariCookieReadError, + ); + } + }); + + it("accepts the checksum and property-list trailer Safari writes", () => { + const file = encodeBinaryCookies([ + { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + const checksum = Buffer.alloc(8); + const plist = Buffer.from("bplist00 stub"); + const plistLength = Buffer.alloc(4); + plistLength.writeUInt32BE(plist.length, 0); + + expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1); + expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1); + }); + + it("rejects a jar whose page table stops short of its contents", () => { + // A second, undeclared page after the first would be silently dropped — + // the cookies it holds vanish from the import with no error — so a file + // the header does not fully describe is refused instead. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + const extraPage = encodeBinaryCookies([ + { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 }, + ]).subarray(12); + + expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( + SafariCookieReadError, + ); + // A trailer that claims a property list it doesn't contain is refused too. + const badLength = Buffer.alloc(4); + badLength.writeUInt32BE(99, 0); + expect(() => + parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])), + ).toThrow(SafariCookieReadError); + }); + + it("rejects a file that is not binarycookies", () => { + expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( + SafariCookieReadError, + ); + }); +}); + +describe("readSafariCookies", () => { + it.effect("adds the cookie path and parser cause to malformed jar failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFileString(jar, "not a cookie jar"); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + assert.equal(error.cookieDatabasePath, jar); + assert.instanceOf(error.cause, SafariCookieReadError); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a TCC denial as a permission the user can grant", () => + Effect.gen(function* () { + // What Full Disk Access actually looks like: the file is there, the read + // is refused with EPERM. Effect tags that `Unknown`, not + // `PermissionDenied`, so the reader has to look at the errno. Reporting + // it as a generic failure would send the user looking for a missing + // browser instead of a checkbox. + const denied = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "/protected/Cookies.binarycookies", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + + const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe( + Effect.flip, + Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })), + ); + + assert.equal(error.reason, "needsFullDiskAccess"); + }), + ); + + it.effect("reports an ordinary permission failure as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix + // it, so it must not be routed to that grant. + yield* fileSystem.chmod(jar, 0o000); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a missing jar as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + + const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("safariAccessDenied", () => { + const eperm = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "open", + pathOrDescriptor: "/protected/Cookies.binarycookies", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + const denied = (error: PlatformError.PlatformError) => + FileSystem.layerNoop({ open: () => Effect.fail(error) }); + + it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => + Effect.gen(function* () { + // `stat` finds the jar without the grant, so only an open tells the + // listing whether the import would actually be allowed. + assert.isTrue( + yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( + Effect.provide(denied(eperm)), + ), + ); + }), + ); + + it.effect("does not read a readable jar, or any other failure, as denied", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + assert.isFalse(yield* safariAccessDenied(jar)); + // Missing entirely is "not installed", not "denied". + assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("isPermissionDenied", () => { + // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified + // against Safari's TCC-protected jar, whose denial is EPERM, tagged + // `Unknown` rather than `PermissionDenied`. + const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => + ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; + + it("treats a TCC EPERM denial as permission denied", () => { + // The regression: EPERM is tagged `Unknown`, so checking the tag alone + // reported Safari's Full Disk Access refusal as a generic read failure. + expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); + }); + + it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { + // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk + // Access, so it stays a plain read failure; only TCC's EPERM routes there. + expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); + }); + + it("does not treat an unrelated failure as permission denied", () => { + expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts new file mode 100644 index 000000000000..88aa856b83e9 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -0,0 +1,263 @@ +/** + * Safari cookie extraction. + * + * Safari does not encrypt its cookies; it stores them in a proprietary + * `Cookies.binarycookies` file inside its app container. The protection is + * TCC, not cryptography — the file lives under a path only apps with Full Disk + * Access may read, so the gate is a permission the user grants in System + * Settings rather than a key to obtain. + * + * The format, big-endian throughout except the page bodies: + * + * magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page: + * u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount], + * then each cookie: + * u32le size, u32le unknown, u32le flags, u32le unknown, + * u32le urlOffset, nameOffset, pathOffset, valueOffset, + * u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated + * strings at the offsets above (relative to the cookie start). + * + * @module SafariCookies + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts"; + +/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */ +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ +const COOKIE_PAGE_HEADER_SIZE = 12; +/** Through the `f64 creation` field; string bytes follow. */ +const COOKIE_RECORD_HEADER_SIZE = 56; + +const FLAG_SECURE = 0x1; +const FLAG_HTTP_ONLY = 0x4; + +export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]); +export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type; + +export class SafariCookieReadError extends Schema.TaggedErrorClass()( + "SafariCookieReadError", + { + reason: SafariCookieReadFailure, + /** + * Which jar the read was for. The parser raises this before a path is in + * hand, so it is optional rather than required. + */ + cookieDatabasePath: Schema.optional(Schema.String), + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.cookieDatabasePath === undefined + ? `Could not read Safari cookies: ${this.reason}.` + : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`; + } +} + +const isSafariCookieReadError = Schema.is(SafariCookieReadError); + +/** Reads a NUL-terminated ASCII string at an offset. */ +function readCString(buffer: Buffer, start: number): string { + const end = buffer.indexOf(0, start); + return buffer.toString("utf8", start, end === -1 ? buffer.length : end); +} + +export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { + if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + + const pageCount = buffer.readUInt32BE(4); + // Every declared structure is bounds-checked against what the file actually + // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently, + // so accepting a short page or an overlong record would return a cookie set + // that is quietly missing entries or carrying fields read out of the next + // record — a partial import the user has no way to notice. + if (8 + pageCount * 4 > buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const pageSizes: number[] = []; + for (let index = 0; index < pageCount; index += 1) { + pageSizes.push(buffer.readUInt32BE(8 + index * 4)); + } + + const cookies: ImportedCookie[] = []; + let pageStart = 8 + pageCount * 4; + + for (const pageSize of pageSizes) { + if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const page = buffer.subarray(pageStart, pageStart + pageSize); + pageStart += pageSize; + + // Page bodies switch to little-endian after the big-endian header. + const cookieCount = page.readUInt32LE(4); + const offsetTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4; + if (offsetTableEnd > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + // Every record accepted so far, so a later offset cannot point back into + // one of them: the page header, the offset table, and earlier records are + // all bytes that would otherwise parse as a fabricated cookie. + const accepted: Array = []; + for (let index = 0; index < cookieCount; index += 1) { + const cookieStart = page.readUInt32LE(8 + index * 4); + if (cookieStart < offsetTableEnd || cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + // Bounded by the record's own length so a string offset cannot run past + // it into the following record's bytes. + const recordSize = page.readUInt32LE(cookieStart); + const cookieEnd = cookieStart + recordSize; + if ( + recordSize < COOKIE_RECORD_HEADER_SIZE || + cookieEnd > page.length || + accepted.some(([start, end]) => cookieStart < end && cookieEnd > start) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + accepted.push([cookieStart, cookieEnd]); + const cookie = page.subarray(cookieStart, cookieEnd); + + const flags = cookie.readUInt32LE(8); + const urlOffset = cookie.readUInt32LE(16); + const nameOffset = cookie.readUInt32LE(20); + const pathOffset = cookie.readUInt32LE(24); + const valueOffset = cookie.readUInt32LE(28); + const expiry = cookie.readDoubleLE(40); + + // Offsets are relative to the record; one pointing outside it would + // otherwise read a neighbouring cookie's bytes as this one's value. + if ( + [urlOffset, nameOffset, pathOffset, valueOffset].some( + (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length, + ) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const domain = readCString(cookie, urlOffset); + const name = readCString(cookie, nameOffset); + const path = readCString(cookie, pathOffset); + const value = readCString(cookie, valueOffset); + if (domain === "" || name === "") continue; + + const secure = (flags & FLAG_SECURE) !== 0; + const expirationDate = + expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined; + + cookies.push({ + // Safari marks domain cookies with a leading dot like the other + // engines, so the shared scope rule applies: host-only cookies keep + // `domain` undefined, or Electron widens them to every subdomain. + ...cookieScope(domain, path || "/", secure), + name, + value, + path: path || "/", + secure, + httpOnly: (flags & FLAG_HTTP_ONLY) !== 0, + expirationDate, + // Bits 3–5 of the flags carry something SameSite-shaped, but no public + // description of them agrees and real jars do not match any of them + // cleanly. Lax is the modern browser default; claiming "none" would + // widen every imported cookie's scope. + sameSite: "lax", + }); + } + } + + // Safari writes an 8-byte checksum after the pages, then an optional + // length-prefixed property list. Anything else past the declared pages — + // in particular whole extra pages — means the page table does not describe + // the file, and a jar the header lies about is refused rather than + // imported with cookies silently missing. + const trailer = buffer.length - pageStart; + // Legal shapes: nothing, the 8-byte checksum alone, or checksum + u32 + // length + exactly that many property-list bytes. + const validTrailer = + trailer === 0 || + trailer === 8 || + (trailer >= 12 && trailer === 8 + 4 + buffer.readUInt32BE(pageStart + 8)); + if (!validTrailer) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + + return cookies; +} + +/** + * Whether a filesystem error is the OS refusing access. + * + * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than + * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked + * too — otherwise a Full Disk Access refusal is reported as a generic read + * failure and the user is never told what to grant. + */ +export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => { + // TCC denies with EPERM, which Effect tags `Unknown` rather than + // `PermissionDenied` — so the errno is what identifies it. EACCES (and the + // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or + // ACL failure that granting Full Disk Access cannot fix, so it stays a plain + // read failure rather than sending the user to a grant that won't help. + const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; + return code === "EPERM"; +}; + +/** + * Whether reading the jar is refused by TCC. `stat` succeeds on the jar + * inside Safari's container even without Full Disk Access — that is what lets + * the listing find it — so presence alone cannot tell granted from denied. + * Opening it for read is what TCC gates: EPERM means the grant is missing. + * Anything else (including a missing jar) is not a permission answer. + */ +export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( + Effect.as(false), + Effect.catch((cause) => Effect.succeed(isPermissionDenied(cause))), + Effect.scoped, + ); +}); + +export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* ( + cookiePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem.readFile(cookiePath).pipe( + Effect.mapError((cause) => { + // TCC denies the read even though the file exists — a permission the user + // grants in System Settings rather than a missing browser. macOS never + // prompts for Full Disk Access, so there is no dialog to wait on; the + // read just fails, and it fails with EPERM, which Effect surfaces as an + // `Unknown` system error rather than `PermissionDenied` (that is EACCES). + return new SafariCookieReadError({ + reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed", + cookieDatabasePath: cookiePath, + cause, + }); + }), + ); + // The parser throws on a malformed jar; catch it here so callers see a typed + // failure rather than a defect. + return yield* Effect.try({ + try: () => parseBinaryCookies(Buffer.from(contents)), + catch: (cause) => + isSafariCookieReadError(cause) + ? new SafariCookieReadError({ + reason: cause.reason, + cookieDatabasePath: cookiePath, + cause, + }) + : new SafariCookieReadError({ + reason: "readFailed", + cookieDatabasePath: cookiePath, + cause, + }), + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index feaac842cbef..a867f78497b4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie // table with the same native bindings the source reads. +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { @@ -32,6 +33,7 @@ import { sourcePathContext, windowsChromiumCookiesAreHeld, } from "./Sources.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -125,7 +127,7 @@ const writeFirefoxCookieDatabase = ( }); describe("Helium on Linux", () => { - it.effect("discovers its profiles and checks the user-data lock", () => + it.effect.skipIf(!symlinksSupported)("discovers its profiles and checks the user-data lock", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -221,48 +223,52 @@ describe("isSourceRunning", () => { ), ); - it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - assert.isFalse(yield* isSourceRunning(helium, context)); - - // Chromium points the lock at `-`, a target that never - // exists on disk. A check that follows the link reports a running - // browser as closed, letting an import read a live, mid-write database. - yield* fileSystem.symlink( - "host-that-does-not-exist-1234", - `${userDataDirectory(context)}/SingletonLock`, - ); + it.effect.skipIf(!symlinksSupported)( + "reads Chromium's dangling SingletonLock symlink as a running browser", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + assert.isFalse(yield* isSourceRunning(helium, context)); + + // Chromium points the lock at `-`, a target that never + // exists on disk. A check that follows the link reports a running + // browser as closed, letting an import read a live, mid-write database. + yield* fileSystem.symlink( + "host-that-does-not-exist-1234", + `${userDataDirectory(context)}/SingletonLock`, + ); - assert.isTrue(yield* isSourceRunning(helium, context)); - }), - ), + assert.isTrue(yield* isSourceRunning(helium, context)); + }), + ), ); - it.effect("uses the provided hostname to classify Chromium locks", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - yield* fileSystem.symlink( - "lock-owner-99999999", - `${helium.userDataDirectory(paths)}/SingletonLock`, - ); + it.effect.skipIf(!symlinksSupported)( + "uses the provided hostname to classify Chromium locks", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + yield* fileSystem.symlink( + "lock-owner-99999999", + `${helium.userDataDirectory(paths)}/SingletonLock`, + ); - assert.isTrue( - yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcessHostname, "another-host"), - ), - ); - assert.isFalse( - yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcessHostname, "lock-owner"), - ), - ); - }), - ), + assert.isTrue( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "another-host"), + ), + ); + assert.isFalse( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "lock-owner"), + ), + ); + }), + ), ); }); @@ -385,25 +391,27 @@ describe("isSourceInstalled", () => { ), ); - it.effect("follows cookie database symlinks when detecting profiles", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = userDataDirectory(context); - yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`); + it.effect.skipIf(!symlinksSupported)( + "follows cookie database symlinks when detecting profiles", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`); - assert.deepEqual(yield* listSourceProfiles(helium, context), []); - assert.isFalse(yield* isSourceInstalled(helium, context)); + assert.deepEqual(yield* listSourceProfiles(helium, context), []); + assert.isFalse(yield* isSourceInstalled(helium, context)); - yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); - assert.deepEqual(yield* listSourceProfiles(helium, context), [ - { directory: "Default", name: "Default" }, - ]); - assert.isTrue(yield* isSourceInstalled(helium, context)); - }), - ), + yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), ); }); @@ -524,7 +532,7 @@ Path=Profiles/wxyz.empty yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true }); assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/abcd.default-release", name: "original" }, + { directory: context.path.join("Profiles", "abcd.default-release"), name: "original" }, ]); }), ), @@ -605,10 +613,16 @@ describe("cookieDatabaseCandidatePaths", () => { run( Effect.gen(function* () { const context = yield* withSourceHome(); - const profile = `${context.home}/Library/Application Support/net.imput.helium/Profile 1`; + const profile = context.path.join( + context.home, + "Library", + "Application Support", + "net.imput.helium", + "Profile 1", + ); assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [ - `${profile}/Network/Cookies`, - `${profile}/Cookies`, + context.path.join(profile, "Network", "Cookies"), + context.path.join(profile, "Cookies"), ]); }), ), @@ -619,7 +633,7 @@ describe("cookieDatabaseCandidatePaths", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); - const root = helium.userDataDirectory(context); + const root = userDataDirectory(context); // Chromium 96+ keeps sessions in Network/; a root Cookies left behind // by the move is stale and must not be the one imported. yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); @@ -628,7 +642,7 @@ describe("cookieDatabaseCandidatePaths", () => { assert.equal( yield* resolveCookieDatabase(helium, context, "Default"), - `${root}/Default/Network/Cookies`, + context.path.join(root, "Default", "Network", "Cookies"), ); // A fresh install with only the Network/ jar is installed, not hidden. yield* fileSystem.remove(`${root}/Default/Cookies`); @@ -660,44 +674,48 @@ describe("cookieDatabaseCandidatePaths", () => { const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; describe("Firefox Snap profiles", () => { - it.effect("finds Snap profiles with or without profiles.ini and checks their locks", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" }); - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "linux"), - ); - const root = `${home}/snap/firefox/common/.mozilla/firefox`; - const directory = `${root}/abcd.default`; - yield* fileSystem.makeDirectory(directory, { recursive: true }); - yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1); - yield* fileSystem.writeFileString( - `${root}/profiles.ini`, - "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n", - ); + it.effect.skipIf(!symlinksSupported)( + "finds Snap profiles with or without profiles.ini and checks their locks", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-firefox-snap-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "linux"), + ); + const root = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"); + const directory = context.path.join(root, "abcd.default"); + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n", + ); - assert.isTrue(yield* isSourceInstalled(firefox, context)); - assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory, name: "Personal", cookieCount: 2 }, - ]); - assert.equal( - yield* resolveCookieDatabase(firefox, context, directory), - `${directory}/cookies.sqlite`, - ); - assert.isFalse(yield* isSourceRunning(firefox, context)); - yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`); - assert.isTrue(yield* isSourceRunning(firefox, context)); - yield* fileSystem.remove(`${directory}/lock`); - assert.isFalse(yield* isSourceRunning(firefox, context)); + assert.isTrue(yield* isSourceInstalled(firefox, context)); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "Personal", cookieCount: 2 }, + ]); + assert.equal( + yield* resolveCookieDatabase(firefox, context, directory), + context.path.join(directory, "cookies.sqlite"), + ); + assert.isFalse(yield* isSourceRunning(firefox, context)); + yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`); + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* fileSystem.remove(`${directory}/lock`); + assert.isFalse(yield* isSourceRunning(firefox, context)); - yield* fileSystem.remove(`${root}/profiles.ini`); - assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory, name: "abcd.default", cookieCount: 2 }, - ]); - }), - ), + yield* fileSystem.remove(`${root}/profiles.ini`); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "abcd.default", cookieCount: 2 }, + ]); + }), + ), ); it.effect("keeps matching profile names in native and Snap installs distinct", () => @@ -709,8 +727,8 @@ describe("Firefox Snap profiles", () => { Effect.provideService(HostProcessEnvironment, { HOME: home }), Effect.provideService(HostProcessPlatform, "linux"), ); - const native = `${home}/.mozilla/firefox`; - const snap = `${home}/snap/firefox/common/.mozilla/firefox`; + const native = context.path.join(home, ".mozilla", "firefox"); + const snap = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"); for (const root of [native, snap]) { yield* fileSystem.makeDirectory(`${root}/abcd.default`, { recursive: true }); yield* writeFirefoxCookieDatabase(`${root}/abcd.default/cookies.sqlite`, 1, 0); @@ -724,14 +742,14 @@ describe("Firefox Snap profiles", () => { const profiles = yield* listSourceProfiles(firefox, context); assert.deepEqual( profiles.map((profile) => profile.directory), - ["abcd.default", `${snap}/abcd.default`], + ["abcd.default", context.path.join(snap, "abcd.default")], ); const databases = yield* Effect.forEach(profiles, (profile) => resolveCookieDatabase(firefox, context, profile.directory), ); assert.deepEqual(databases, [ - `${native}/abcd.default/cookies.sqlite`, - `${snap}/abcd.default/cookies.sqlite`, + context.path.join(native, "abcd.default", "cookies.sqlite"), + context.path.join(snap, "abcd.default", "cookies.sqlite"), ]); }), ), @@ -741,8 +759,8 @@ describe("Firefox Snap profiles", () => { describe("listSourceProfiles Firefox fallback", () => { const cases = [ { platform: "linux" as const, profileDirectory: "linux.default" }, - { platform: "darwin" as const, profileDirectory: "Profiles/macos.default" }, - { platform: "win32" as const, profileDirectory: "Profiles/windows.default" }, + { platform: "darwin" as const, profileDirectory: NodePath.join("Profiles", "macos.default") }, + { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") }, ]; for (const { platform, profileDirectory } of cases) { @@ -813,7 +831,11 @@ describe("listSourceProfiles Firefox fallback", () => { // Returning the empty declared list would hide the browser entirely. assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/real.default", name: "real.default", cookieCount: 3 }, + { + directory: path.join("Profiles", "real.default"), + name: "real.default", + cookieCount: 3, + }, ]); assert.isTrue(yield* isSourceInstalled(firefox, context)); }), @@ -844,7 +866,11 @@ describe("listSourceProfiles Firefox fallback", () => { ); assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/declared.default", name: "Declared", cookieCount: 2 }, + { + directory: path.join("Profiles", "declared.default"), + name: "Declared", + cookieCount: 2, + }, ]); yield* fileSystem.remove(path.join(root, "profiles.ini")); @@ -853,8 +879,16 @@ describe("listSourceProfiles Firefox fallback", () => { yield* writeFirefoxCookieDatabase(path.join(fallbackDirectory, "cookies.sqlite"), 1, 4); assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/declared.default", name: "declared.default", cookieCount: 2 }, - { directory: "Profiles/fallback.default", name: "fallback.default", cookieCount: 1 }, + { + directory: path.join("Profiles", "declared.default"), + name: "declared.default", + cookieCount: 2, + }, + { + directory: path.join("Profiles", "fallback.default"), + name: "fallback.default", + cookieCount: 1, + }, ]); }), ), @@ -862,7 +896,7 @@ describe("listSourceProfiles Firefox fallback", () => { }); describe("isSourceRunning for Firefox", () => { - it.effect("finds the lock inside the profile, not at the root", () => + it.effect.skipIf(!symlinksSupported)("finds the lock inside the profile, not at the root", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -917,53 +951,56 @@ describe("isSourceRunning for Firefox", () => { ), ); - it.effect("detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), - ); - const root = firefox.userDataDirectory(context)!; - const profile = `${root}/Profiles/abcd.default-release`; - yield* fileSystem.makeDirectory(profile, { recursive: true }); - yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - const parentLock = `${profile}/.parentlock`; - yield* fileSystem.writeFileString(parentLock, ""); - - // Hold the lock from a child the way Firefox does (F_SETLK, write), - // and keep it until the scope closes. - const holder = yield* spawner.spawn( - ChildProcess.make( - "python3", - [ - "-c", - "import fcntl,os,sys,time\n" + - "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + - "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + - "print('locked',flush=True)\n" + - "time.sleep(30)", - parentLock, - ], - { stdin: "ignore" }, - ), - ); - // Wait for the child to confirm it holds the lock before probing. - yield* holder.stdout.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.filter((line) => line.trim() === "locked"), - Stream.take(1), - Stream.runDrain, - ); + // Holds the lock with python3's fcntl, which does not exist on Windows. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/abcd.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); + const parentLock = `${profile}/.parentlock`; + yield* fileSystem.writeFileString(parentLock, ""); + + // Hold the lock from a child the way Firefox does (F_SETLK, write), + // and keep it until the scope closes. + const holder = yield* spawner.spawn( + ChildProcess.make( + "python3", + [ + "-c", + "import fcntl,os,sys,time\n" + + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + + "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + + "print('locked',flush=True)\n" + + "time.sleep(30)", + parentLock, + ], + { stdin: "ignore" }, + ), + ); + // Wait for the child to confirm it holds the lock before probing. + yield* holder.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filter((line) => line.trim() === "locked"), + Stream.take(1), + Stream.runDrain, + ); - assert.isTrue(yield* isSourceRunning(firefox, context)); - yield* holder.kill(); - }), - ), + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* holder.kill(); + }), + ), ); it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => @@ -1056,3 +1093,106 @@ describe("listSourceProfiles hardening", () => { ), ); }); + +describe("Safari profiles", () => { + const safari = BROWSER_IMPORT_SOURCES.find((source) => source.id === "safari")!; + const workUuid = "C561D071-67AD-4537-866F-54F65FB8E8DD"; + const otherUuid = "2875EB19-B938-4E38-BE92-5AE97C256BDD"; + + const fixture = Effect.fnUntraced(function* () { + const context = yield* withSourceHome(); + const fileSystem = yield* FileSystem.FileSystem; + const root = safari.userDataDirectory(context)!; + const library = context.path.dirname(root); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.writeFileString(context.path.join(root, "Cookies.binarycookies"), "default"); + const store = (uuid: string) => + context.path.join(library, "WebKit", "WebsiteDataStore", uuid.toLowerCase(), "Cookies"); + for (const uuid of [workUuid, otherUuid]) { + yield* fileSystem.makeDirectory(store(uuid), { recursive: true }); + yield* fileSystem.writeFileString( + context.path.join(store(uuid), "Cookies.binarycookies"), + uuid, + ); + } + yield* fileSystem.makeDirectory(context.path.join(library, "Safari"), { recursive: true }); + const metadata = context.path.join(library, "Safari", "SafariTabs.db"); + return { context, root, store, metadata }; + }); + + it.effect("discovers named profiles and resolves only the selected profile's cookies", () => + run( + Effect.gen(function* () { + const { context, root, store, metadata } = yield* fixture(); + yield* Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(metadata); + try { + database.exec(`CREATE TABLE bookmarks ( + title TEXT, external_uuid TEXT, parent INTEGER DEFAULT 0, + type INTEGER DEFAULT 1, subtype INTEGER DEFAULT 2, + deleted INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0 + )`); + const insert = database.prepare( + "INSERT INTO bookmarks (title, external_uuid, deleted) VALUES (?, ?, ?)", + ); + insert.run("", "DefaultProfile", 0); + insert.run("Ping", workUuid, 0); + insert.run("Deleted", otherUuid, 1); + insert.run("Unsafe", "../../outside", 0); + database.exec( + "INSERT INTO bookmarks (title, external_uuid, subtype) VALUES ('Tab group', 'group', 1)", + ); + } finally { + database.close(); + } + }); + const profiles = yield* listSourceProfiles(safari, context); + assert.deepEqual(profiles, [ + { directory: ".", name: "Personal" }, + { directory: store(workUuid), name: "Ping" }, + ]); + assert.strictEqual( + yield* resolveCookieDatabase(safari, context, "."), + context.path.join(root, "Cookies.binarycookies"), + ); + const selected = yield* resolveCookieDatabase(safari, context, profiles[1]!.directory); + assert.strictEqual(selected, context.path.join(store(workUuid), "Cookies.binarycookies")); + const fileSystem = yield* FileSystem.FileSystem; + assert.strictEqual(yield* fileSystem.readFileString(selected!), workUuid); + yield* fileSystem.remove(selected!); + assert.isUndefined(yield* resolveCookieDatabase(safari, context, profiles[1]!.directory)); + assert.deepEqual(yield* listSourceProfiles(safari, context), profiles); + }), + ), + ); + + for (const metadataState of ["missing", "corrupt"] as const) { + it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () => + run( + Effect.gen(function* () { + const { context, store, metadata } = yield* fixture(); + const fileSystem = yield* FileSystem.FileSystem; + if (metadataState === "corrupt") yield* fileSystem.writeFileString(metadata, "invalid"); + yield* fileSystem.remove(context.path.join(store(otherUuid), "Cookies.binarycookies")); + assert.deepEqual(yield* listSourceProfiles(safari, context), [ + { directory: ".", name: "Safari" }, + { directory: store(workUuid), name: workUuid.toLowerCase() }, + ]); + assert.isTrue(yield* isSourceInstalled(safari, context)); + }), + ), + ); + } + + it.effect("keeps Safari without profiles available", () => + run( + Effect.gen(function* () { + const context = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(safari, context), [ + { directory: ".", name: "Safari" }, + ]); + assert.isFalse(yield* isSourceInstalled(safari, context)); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 702933a432b3..6075f0ad56a3 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -1,10 +1,11 @@ /** * Importable browser sources. * - * Two engines are modelled. Chromium-family browsers keep cookies in an + * Chromium-family browsers keep cookies in an * encrypted SQLite database whose key lives in an OS credential store; Firefox * keeps them in plain SQLite with no key at all, so it needs no keychain and - * works the same on every platform. + * works the same on every platform. Safari uses binary cookie files, with + * separate WebKit data stores for named profiles. * * Each entry pins its own paths and credential-store coordinates rather than * deriving them, because the forks do not agree. macOS uses service/account @@ -31,7 +32,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -export type BrowserImportEngine = "chromium" | "firefox"; +export type BrowserImportEngine = "chromium" | "firefox" | "safari"; /** * Directory roots a definition builds its paths from. Passed in rather than @@ -177,6 +178,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray + context.platform === "darwin" + ? context.path.join( + context.home, + "Library", + "Containers", + "com.apple.Safari", + "Data", + "Library", + "Cookies", + ) + : undefined, + }, { id: "firefox", name: "Firefox", @@ -220,6 +241,9 @@ export const cookieDatabaseCandidatePaths = ( if (definition.engine === "firefox") { return [context.path.join(profilePath, "cookies.sqlite")]; } + if (definition.engine === "safari") { + return [context.path.join(profilePath, "Cookies.binarycookies")]; + } // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade // leaves the legacy file behind, so prefer the current one and fall back. return [ @@ -386,6 +410,64 @@ const withCookieCounts = ( ), ); +const SafariProfileRows = Schema.Array( + Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }), +); +const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows); +const isSafariProfileUuid = (value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); + +const listSafariProfiles = Effect.fnUntraced(function* ( + context: BrowserImportPathContext, + root: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const library = context.path.dirname(root); + const metadata = context.path.join(library, "Safari", "SafariTabs.db"); + const declared = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* decodeSafariProfiles( + yield* sql` + select title, external_uuid from bookmarks + where parent = 0 and type = 1 and subtype = 2 and deleted = 0 + order by order_index + `, + ); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })), + Effect.orElseSucceed(() => []), + ); + const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile"); + const profiles: Array = [ + { + directory: ".", + name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari", + }, + ]; + const stores = context.path.join(library, "WebKit", "WebsiteDataStore"); + const profileDirectory = (uuid: string) => + context.path.join(stores, uuid.toLowerCase(), "Cookies"); + for (const profile of declared) { + if (!isSafariProfileUuid(profile.external_uuid)) continue; + profiles.push({ + directory: profileDirectory(profile.external_uuid), + name: profile.title?.trim() || profile.external_uuid, + }); + } + // If Safari's metadata is unavailable, recover stores that have cookies. + // With readable metadata, avoid resurrecting deleted profiles left on disk. + if (declared.length === 0) { + const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries.filter(isSafariProfileUuid).sort()) { + const directory = context.path.join(stores, entry, "Cookies"); + if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) { + profiles.push({ directory, name: entry }); + } + } + } + return profiles; +}); + /** * Profiles the source browser knows about. * @@ -403,6 +485,10 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const root = definition.userDataDirectory(context); if (root === undefined) return []; + if (definition.engine === "safari") { + return yield* listSafariProfiles(context, root); + } + if (definition.engine === "firefox") { const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), @@ -773,11 +859,15 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const root = definition.userDataDirectory(context); if (root === undefined) return false; // Probe the source's own lock state rather than scanning the process table. + // Safari keeps no lock and writes its jar atomically, so a running instance + // is not a hazard there. + // // Chromium exposes its lock through the cookie jar on Windows and through a // user-data SingletonLock on POSIX. Firefox keeps its locks inside each // profile under three names across platforms (`lock` on macOS and Linux, // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's // at the root finds nothing and reports a running browser as importable. + if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { return yield* windowsChromiumCookiesAreHeld(definition, context); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a7b3afabd3c3..79c7fd1725e1 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -272,6 +272,7 @@ const makeTestPreviewWebContents = ( ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -382,6 +383,7 @@ const makeFaviconWebContents = (options?: { send: webviewSend, session: { fetch }, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), executeJavaScriptInIsolatedWorld, debugger: { @@ -474,6 +476,67 @@ describe("PreviewManager", () => { webviewSend.mockClear(); }); + effectIt.effect("keeps preview shortcuts out of the host window", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const sendInputEvent = vi.fn(); + const hostWebContents = { sendInputEvent }; + Object.assign(preview.webContents, { hostWebContents }); + fromId.mockReturnValue(preview.webContents); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: hostWebContents, + } as never); + yield* manager.createTab("tab_keys"); + yield* manager.registerWebview("tab_keys", 42); + + expect( + (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts, + ).toHaveBeenCalledWith(true); + const beforeInput = preview.listeners.get("before-input-event")!; + for (const control of [false, true]) { + for (const key of ["k", ",", "w", "j", "q", "+", "a", "c", "v", "x"]) { + for (const type of ["keyDown", "keyUp"]) { + const preventDefault = vi.fn(); + beforeInput( + { preventDefault } as never, + { type, key, meta: !control, control, shift: key === "j", alt: false } as never, + ); + yield* Effect.yieldNow; + expect(preventDefault).not.toHaveBeenCalled(); + } + } + } + expect(sendInputEvent).not.toHaveBeenCalled(); + + const preventDefault = vi.fn(); + beforeInput( + { preventDefault } as never, + { + type: "keyDown", + key: "r", + meta: true, + control: false, + shift: false, + alt: false, + } as never, + ); + yield* Effect.yieldNow; + expect(preventDefault).toHaveBeenCalledOnce(); + expect(preview.reload).toHaveBeenCalledOnce(); + expect(sendInputEvent).not.toHaveBeenCalled(); + + const setIgnoreMenuShortcuts = vi.fn(); + preview.listeners.get("did-create-window")!({ + webContents: { setIgnoreMenuShortcuts, setWindowOpenHandler: vi.fn() }, + } as never); + expect(setIgnoreMenuShortcuts).toHaveBeenCalledWith(true); + }), + ), + ); + effectIt.effect("reports an unregistered webview as temporarily unavailable", () => withManager((manager) => Effect.gen(function* () { @@ -617,6 +680,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -718,6 +782,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), get debugger() { if (destroyed) throw new Error("Object has been destroyed"); @@ -1222,6 +1287,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1286,6 +1352,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1326,6 +1393,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1372,6 +1440,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1427,6 +1496,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1524,6 +1594,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1859,6 +1930,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1951,6 +2023,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1987,10 +2060,30 @@ describe("PreviewManager", () => { /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, ); + // Chromium reports UnknownVizError while a hidden guest warms its + // first compositor frame, so transient failures are retried. + capturePage.mockClear(); + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + const retriedFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(1_000); + const retriedExit = yield* Fiber.join(retriedFiber); + expect(Exit.isSuccess(retriedExit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); + + // A persistent failure still surfaces once the retries are spent. + capturePage.mockClear(); const captureCause = new Error("capture failed"); - capturePage.mockRejectedValueOnce(captureCause); - const exit = yield* Effect.exit(manager.captureScreenshot("tab_1")); + capturePage.mockRejectedValue(captureCause); + const failingFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(1_000); + const exit = yield* Fiber.join(failingFiber); expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); expect(error).toMatchObject({ @@ -2324,6 +2417,127 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("stops capture retries when the tab swaps during the retry delay", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toPNG: () => Buffer.from("png"), + toJPEG: () => Buffer.from("jpeg"), + getSize: () => ({ width: 100, height: 80 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + // Let the rejection schedule its retry before replacing the guest. + yield* TestClock.adjust(60); + expect(capturePage).toHaveBeenCalledTimes(1); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); + yield* manager.registerWebview("tab_1", 43); + yield* TestClock.adjust(1_000); + const exit = yield* Fiber.join(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(1); + expect(writeFile).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => + withManager((manager) => + Effect.gen(function* () { + const image = { + toPNG: () => Buffer.from("stale-png"), + toJPEG: () => Buffer.from("stale-jpeg"), + getSize: () => ({ width: 100, height: 80 }), + }; + const pending = Promise.withResolvers(); + const capturePage = vi.fn(() => pending.promise); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledOnce(); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); + yield* manager.registerWebview("tab_1", 43); + pending.resolve(image); + const exit = yield* Fiber.join(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("releases snapshot control when every capture attempt stalls", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(() => new Promise(() => {})); + const wc = makeTestPreviewWebContents(capturePage); + Object.assign(wc, { isDevToolsOpened: () => false }); + Object.assign(wc.debugger, { + sendCommand: vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: + params?.["expression"] === "42" + ? 42 + : { + url: "https://example.com", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + }, + }, + }; + } + return method === "Accessibility.getFullAXTree" ? { nodes: [] } : undefined; + }), + }); + fromId.mockReturnValue(wc); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const snapshot = yield* Effect.exit(manager.automationSnapshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(100); + expect(capturePage).toHaveBeenCalledOnce(); + const evaluate = yield* manager + .automationEvaluate("tab_1", { expression: "42" }) + .pipe(Effect.forkChild({ startImmediately: true })); + expect(evaluate.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust(4_000); + const exit = yield* Fiber.join(snapshot); + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewOperationError", + operation: "automationSnapshot.capturePage", + tabId: "tab_1", + webContentsId: 42, + cause: { _tag: "TimeoutError" }, + }); + expect(yield* Fiber.join(evaluate)).toBe(42); + }), + ), + ); + effectIt.effect("grants each concurrent preview recording its own tab frame", () => withManager((manager) => Effect.gen(function* () { @@ -2368,6 +2582,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -2670,6 +2885,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3173,6 +3389,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3228,6 +3445,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3304,6 +3522,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3389,6 +3608,7 @@ describe("PreviewManager", () => { goBack, goForward, }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3518,6 +3738,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3618,6 +3839,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3773,6 +3995,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3837,6 +4060,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 324b92034f36..900ba5fe983c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -48,6 +48,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -113,6 +114,13 @@ const MAX_SCREENSHOT_WIDTH = 1280; const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; +/** + * Cold guests can reject capturePage with UnknownVizError or never settle it. + * Bound each attempt so snapshots release control even when Chromium stalls. + */ +const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; +const CAPTURE_PAGE_RETRY_DELAY_MS = 120; +const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; const PICTURE_IN_PICTURE_MIN_WIDTH = 240; @@ -465,22 +473,6 @@ interface ExpectedAgentInput { readonly expiresAt: number; } -const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ - key: string; - meta: boolean; - shift: boolean; - control: boolean; -}> = Object.freeze([ - // mod+shift+J → preview.toggle - { key: "j", meta: true, shift: true, control: false }, - // mod+K → command palette - { key: "k", meta: true, shift: false, control: false }, - // mod+, → settings (macOS convention) - { key: ",", meta: true, shift: false, control: false }, - // mod+W → close tab/panel - { key: "w", meta: true, shift: false, control: false }, -]); - /** * Protocols a preview page may open in a real popup window. * @@ -655,6 +647,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function try: evaluate, catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }); + const capturePageWithRetry = Effect.fn("PreviewManager.capturePageWithRetry")(function* ( + errorContext: PreviewOperationContext, + tabId: string, + wc: Electron.WebContents, + ) { + const requireCurrentGuest = Effect.gen(function* () { + const tabs = yield* SynchronizedRef.get(tabsRef); + if (wc.isDestroyed() || tabs.get(tabId)?.webContentsId !== wc.id) { + return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: wc.id }); + } + }); + const capture = Effect.gen(function* () { + // Check after the retry delay, and again before accepting its result. + yield* requireCurrentGuest; + const image = yield* Effect.tryPromise({ + // An abort-signal parameter makes a stalled promise interruptible. + try: (_signal) => wc.capturePage(), + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new PreviewOperationError({ ...errorContext, cause })), + }), + ); + yield* requireCurrentGuest; + return image; + }); + return yield* capture.pipe( + Effect.retry({ + times: CAPTURE_PAGE_RETRY_ATTEMPTS - 1, + schedule: Schedule.spaced(CAPTURE_PAGE_RETRY_DELAY_MS), + while: isPreviewOperationError, + }), + ); + }); const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const currentMillis = Clock.currentTimeMillis; const encodeJson = (errorContext: PreviewOperationContext, value: unknown) => @@ -1535,16 +1563,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); - const isAppShortcut = (input: Electron.Input): boolean => - input.type === "keyDown" && - APP_FORWARDED_SHORTCUTS.some( - (shortcut) => - shortcut.key.toLowerCase() === input.key.toLowerCase() && - shortcut.meta === input.meta && - shortcut.shift === input.shift && - shortcut.control === input.control, - ); - const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { const url = wc.getURL(); const title = wc.getTitle(); @@ -1819,30 +1837,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); }; - const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( - event: Electron.Event, - input: Electron.Input, - ) { - const mainWindow = yield* Ref.get(mainWindowRef); - if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { - return; - } - event.preventDefault(); - mainWindow.value.webContents.sendInputEvent({ - type: "keyDown", - keyCode: input.key, - modifiers: [ - ...(input.meta ? (["meta"] as const) : []), - ...(input.shift ? (["shift"] as const) : []), - ...(input.control ? (["control"] as const) : []), - ...(input.alt ? (["alt"] as const) : []), - ], - }); - }); // A popup opens with Electron's default handler, so the page inside it could // otherwise spawn native windows without limit. Nothing in an OAuth flow // opens a second popup, so the chain stops at the first one. const windowCreated = (window: Electron.BrowserWindow): void => { + window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); }; const beforeInput = (event: Electron.Event, input: Electron.Input): void => { @@ -1855,7 +1854,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } - runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( scope, @@ -1878,6 +1876,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { + // Preview input belongs to the page, including keys injected through CDP. + // Never let it invoke the host application's menu accelerators. + wc.setIgnoreMenuShortcuts(true); wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); wc.on("did-navigate-in-page", syncInPageNavigation); @@ -2691,13 +2692,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - attemptPromise( + capturePageWithRetry( { operation: "captureScreenshot.capturePage", tabId, webContentsId: wc.id, }, - () => wc.capturePage(), + tabId, + wc, ), ]); const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; @@ -3556,13 +3558,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ send("Accessibility.getFullAXTree"), - attemptPromise( + capturePageWithRetry( { operation: "automationSnapshot.capturePage", tabId, webContentsId: wc.id, }, - () => wc.capturePage(), + tabId, + wc, ), Ref.get(diagnosticsRef), Ref.get(actionTimelineRef), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 5a658090f1d2..fe5d05cee464 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -26,7 +26,6 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, - continueThreadsAfterServerUpdate: true, contextWindowMeterEnabled: false, composerCollapseOnBlur: false, composerCollapseOnScroll: true, diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 05b1ca144444..348f6cb3843e 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; @@ -400,11 +401,12 @@ describe("DesktopSavedEnvironments", () => { it.effect("reports saved environment filesystem reads separately from document decoding", () => Effect.gen(function* () { + const path = yield* Path.Path; const baseFileSystem = yield* FileSystem.FileSystem; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-saved-environments-test-", }); - const registryPath = `${baseDir}/userdata/saved-environments.json`; + const registryPath = path.join(baseDir, "userdata", "saved-environments.json"); const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -433,6 +435,7 @@ describe("DesktopSavedEnvironments", () => { it.effect("reports the failed saved environment write operation and path", () => Effect.gen(function* () { const baseFileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-saved-environments-test-", }); @@ -440,7 +443,7 @@ describe("DesktopSavedEnvironments", () => { _tag: "PermissionDenied", module: "FileSystem", method: "makeDirectory", - pathOrDescriptor: `${baseDir}/userdata`, + pathOrDescriptor: path.join(baseDir, "userdata"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -456,11 +459,11 @@ describe("DesktopSavedEnvironments", () => { const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError); assert.equal(error.operation, "create-directory"); - assert.equal(error.path, `${baseDir}/userdata`); + assert.equal(error.path, path.join(baseDir, "userdata")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Desktop saved-environment write failed during create-directory at ${baseDir}/userdata.`, + `Desktop saved-environment write failed during create-directory at ${path.join(baseDir, "userdata")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 56e00b715203..9ad1171598f8 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -281,6 +281,7 @@ function makeTestLayer(input: { return true; }), openPath: () => Effect.void, + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, @@ -382,6 +383,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), openPath: () => Effect.void, + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index ce74cf58e0a3..294a02030a83 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,3 +1,4 @@ +import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; @@ -83,4 +84,10 @@ export default defineConfig({ entry: ["src/preview-pip-preload.ts"], }, ], + test: { + // The Windows lane runs workspace suites concurrently; filesystem-heavy + // desktop integration tests can exceed Vitest's 5 second default there. + testTimeout: 15_000, + setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + }, }); diff --git a/apps/marketing/src/lib/homeMotion.test.ts b/apps/marketing/src/lib/homeMotion.test.ts new file mode 100644 index 000000000000..42f7775a0157 --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { startHomeMotion } from "./homeMotion"; + +class ElementStub extends EventTarget { + properties = new Map(); + style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; + children: ElementStub[] = []; + scrollLeft = 0; + scrollWidth = 1_200; + clientWidth = 400; + matches = () => false; + contains = (target: EventTarget | null) => + target === this || (target instanceof ElementStub && this.children.includes(target)); + querySelectorAll = () => this.children; + getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); + scrollTo = vi.fn((options: ScrollToOptions) => { + this.scrollLeft = options.left ?? this.scrollLeft; + }); +} + +let observers: ObserverStub[] = []; +class ObserverStub { + constructor(private readonly callback: IntersectionObserverCallback) { + observers.push(this); + } + observe = vi.fn(); + disconnect = vi.fn(); + report(target: ElementStub, isIntersecting: boolean) { + this.callback( + [{ target, isIntersecting } as unknown as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } +} + +let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); +let viewport = new EventTarget(); +let reduced = Object.assign(new EventTarget(), { matches: false }); +let fine = Object.assign(new EventTarget(), { matches: true }); +let frames = new Map(); +let dispose: (() => void) | undefined; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + observers = []; + frames = new Map(); + page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); + viewport = new EventTarget(); + reduced = Object.assign(new EventTarget(), { matches: false }); + fine = Object.assign(new EventTarget(), { matches: true }); + vi.stubGlobal("document", page); + vi.stubGlobal( + "window", + Object.assign(viewport, { + matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), + }), + ); + vi.stubGlobal("Node", ElementStub); + vi.stubGlobal("IntersectionObserver", ObserverStub); + let frameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); +}); + +afterEach(() => { + dispose?.(); + dispose = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function fixture() { + const hero = new ElementStub(); + const field = new ElementStub(); + const mark = new ElementStub(); + const otherMark = new ElementStub(); + field.children = [mark, otherMark]; + const endorsements = new ElementStub(); + const caret = new ElementStub(); + dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters< + typeof startHomeMotion + >[0]); + return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! }; +} + +function movePointer(hero: ElementStub, x = 400, y = 600) { + hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); +} + +describe("homepage motion", () => { + it("gates each mark and caret and batches pointer input into one frame", () => { + const { hero, field, mark, otherMark, caret, observer } = fixture(); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + observer.report(mark, true); + observer.report(caret, true); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("running"); + + movePointer(hero, 100, 100); + movePointer(hero); + expect(frames.size).toBe(1); + expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); + const [id, callback] = [...frames][0]!; + frames.delete(id); + callback(0); + expect(field.properties.get("--px")).toBe("18.0px"); + expect(field.properties.get("--py")).toBe("14.0px"); + + movePointer(hero); + page.visibilityState = "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + expect(frames.size).toBe(0); + expect(field.properties.get("--px")).toBe("0px"); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("paused"); + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + reduced.matches = true; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + reduced.matches = false; + fine.matches = false; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + }); + + it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => { + const { endorsements, observer } = fixture(); + expect(vi.getTimerCount()).toBe(0); + observer.report(endorsements, true); + vi.advanceTimersByTime(7_999); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(16_001); + expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([ + 400, 800, 400, + ]); + expect( + endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"), + ).toBe(true); + + endorsements.clientWidth = endorsements.scrollWidth; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + endorsements.clientWidth = 400; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(1); + }); + + it("pauses paging for hover, focus, hidden content, and reduced motion", () => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + const changeVisibility = (visible: boolean) => { + page.visibilityState = visible ? "visible" : "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + }; + const changeMotion = (matches: boolean) => { + reduced.matches = matches; + reduced.dispatchEvent(new Event("change")); + }; + const pauses = [ + [ + () => endorsements.dispatchEvent(new Event("pointerenter")), + () => endorsements.dispatchEvent(new Event("pointerleave")), + ], + [ + () => endorsements.dispatchEvent(new Event("focusin")), + () => + endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })), + ], + [() => changeVisibility(false), () => changeVisibility(true)], + [() => observer.report(endorsements, false), () => observer.report(endorsements, true)], + [() => changeMotion(true), () => changeMotion(false)], + ] as const; + for (const [pause, resume] of pauses) { + pause(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(16_000); + resume(); + expect(vi.getTimerCount()).toBe(1); + } + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(8_000); + expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" }); + endorsements.dispatchEvent(new Event("pointerenter")); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event(event)); + observer.report(endorsements, false); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event("pointerleave")); + viewport.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(60_000); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + }); + + it("cancels pending work and ignores events after cleanup", () => { + const { hero, mark, endorsements, observer } = fixture(); + observer.report(mark, true); + observer.report(endorsements, true); + movePointer(hero); + dispose?.(); + observer.report(mark, true); + movePointer(hero); + reduced.dispatchEvent(new Event("change")); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + expect(frames.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + }); +}); diff --git a/apps/marketing/src/lib/homeMotion.ts b/apps/marketing/src/lib/homeMotion.ts new file mode 100644 index 000000000000..5322eae4406d --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.ts @@ -0,0 +1,176 @@ +/** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */ +export function startHomeMotion({ + hero, + field, + endorsements, + caret, +}: { + hero: HTMLElement; + field: HTMLElement; + endorsements: HTMLElement; + caret: HTMLElement; +}) { + if (typeof IntersectionObserver === "undefined") return () => {}; + + const marks = Array.from(field.querySelectorAll(".hero-float-mark")); + const visible = new Set(); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const finePointer = window.matchMedia("(pointer: fine)"); + const events = new AbortController(); + const eventOptions = { signal: events.signal }; + let disposed = false; + let hovered = endorsements.matches(":hover"); + let focused = endorsements.contains(document.activeElement); + let userControlled = false; + let direction = 1; + let automaticScroll = false; + let pageTimer: ReturnType | undefined; + let pointerFrame: number | undefined; + let pointer: { x: number; y: number } | null = null; + + const canMove = (element: Element) => + !disposed && + visible.has(element) && + document.visibilityState === "visible" && + !reducedMotion.matches; + const canParallax = () => finePointer.matches && marks.some(canMove); + const canPage = () => + canMove(endorsements) && + !hovered && + !focused && + !userControlled && + endorsements.scrollWidth > endorsements.clientWidth; + + function resetPointer() { + if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); + pointerFrame = undefined; + pointer = null; + field.style.setProperty("--px", "0px"); + field.style.setProperty("--py", "0px"); + } + + function updatePaging() { + if (canPage()) { + pageTimer ??= setTimeout(advancePage, 8_000); + return; + } + if (pageTimer !== undefined) clearTimeout(pageTimer); + pageTimer = undefined; + if (automaticScroll) { + automaticScroll = false; + endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" }); + } + } + + function advancePage() { + pageTimer = undefined; + if (!canPage()) return; + const end = endorsements.scrollWidth - endorsements.clientWidth; + const current = endorsements.scrollLeft; + if (current >= end - 1) direction = -1; + else if (current <= 1) direction = 1; + automaticScroll = true; + endorsements.scrollTo({ + left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)), + behavior: "smooth", + }); + updatePaging(); + } + + function update() { + for (const mark of marks) { + mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); + } + caret.style.setProperty("--home-motion-state", canMove(caret) ? "running" : "paused"); + const parallax = canParallax(); + field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); + if (!parallax) resetPointer(); + updatePaging(); + } + + const observer = new IntersectionObserver((entries) => { + if (disposed) return; + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target); + else visible.delete(entry.target); + } + update(); + }); + for (const element of [...marks, endorsements, caret]) observer.observe(element); + + hero.addEventListener( + "pointermove", + (event) => { + if (!canParallax()) return; + pointer = { x: event.clientX, y: event.clientY }; + pointerFrame ??= requestAnimationFrame(() => { + pointerFrame = undefined; + if (!pointer || !canParallax()) return; + const bounds = hero.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) return; + field.style.setProperty( + "--px", + `${(((pointer.x - bounds.left) / bounds.width - 0.5) * 36).toFixed(1)}px`, + ); + field.style.setProperty( + "--py", + `${(((pointer.y - bounds.top) / bounds.height - 0.5) * 28).toFixed(1)}px`, + ); + }); + }, + eventOptions, + ); + hero.addEventListener("pointerleave", resetPointer, eventOptions); + endorsements.addEventListener( + "pointerenter", + () => { + hovered = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "pointerleave", + () => { + hovered = false; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusin", + () => { + focused = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusout", + (event) => { + focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget); + updatePaging(); + }, + eventOptions, + ); + const takeControl = () => { + userControlled = true; + updatePaging(); + }; + endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true }); + endorsements.addEventListener("pointerdown", takeControl, eventOptions); + endorsements.addEventListener("keydown", takeControl, eventOptions); + document.addEventListener("visibilitychange", update, eventOptions); + window.addEventListener("resize", update, eventOptions); + reducedMotion.addEventListener("change", update, eventOptions); + finePointer.addEventListener("change", update, eventOptions); + update(); + + return () => { + if (disposed) return; + disposed = true; + events.abort(); + observer.disconnect(); + update(); + }; +} diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cd28b446ccf1..a4fdc966b9d8 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -355,6 +355,7 @@ const screenshot = await getImage({ - - -
-
-
Implemented architecture
-

T3 Connect Control Plane and Managed Endpoint Flow

-

- T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions - a managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote - connections. The local environment remains the authority that issues environment access - credentials. The hosted relay stores links, reconciles managed endpoint resources, - validates signed proofs, and delivers agent-activity notifications. -

-
- Client -> Relay: Clerk bearer or relay DPoP - Relay -> Environment: relay-signed health and mint proofs - Environment -> Relay: environment bearer plus signed activity proof - Client -> Environment: environment access token plus DPoP -
-
- -
-
-

Security Invariant

-

- A cloud identity is not an environment login. Remote access requires all of the - following: -

-
    -
  • a Clerk-authenticated T3 Connect user;
  • -
  • an active relay link for that user and environment;
  • -
  • a DPoP proof key held by the remote client;
  • -
  • a relay-signed mint request accepted by the linked local environment; and
  • -
  • an environment-issued credential bound to the remote client's DPoP thumbprint.
  • -
-
- The relay cannot mint an environment access token by itself. It can only ask the local - environment to mint a short-lived bootstrap credential for an authorized cloud user. -
-

During an honest connection flow, the relay cannot act as the remote client:

-
    -
  • - Normal environment API traffic goes directly from the remote client through the - managed endpoint. The relay does not receive the resulting environment access token. -
  • -
  • - The one-time bootstrap credential and resulting environment access token are bound to - the remote client's DPoP public-key thumbprint. Redeeming or using them requires - request proofs signed by the corresponding private key, which stays on the client. -
  • -
  • - Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and - replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal - environment API credentials. -
  • -
  • - Environment health and mint responses are signed by the linked environment and bound - to the request nonce, so a different process behind the tunnel cannot impersonate the - linked environment. -
  • -
-
- The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key. - DPoP prevents credential reuse by the relay or tunnel transport during an honest flow, - but it does not make compromise of the relay signing authority harmless. -
-
- -
-

Product Boundaries

-

- The generic local connector is called the relay client. The current - implementation layer uses cloudflared, but that is an internal transport - detail rather than the product contract. -

-
    -
  • - The relay Worker owns cloud links, managed endpoint allocations, and notifications. -
  • -
  • - The environment server owns local sessions, environment keys, and access tokens. -
  • -
  • The relay client exposes only the environment's loopback HTTP server.
  • -
  • - Web and mobile clients connect directly to the managed environment endpoint after - bootstrap. -
  • -
-
-
- -
-

Current Topology

-
-flowchart LR
-  subgraph Clients["User-facing clients"]
-    Web["Desktop / hosted web UI"]
-    Mobile["Mobile app"]
-    CLI["Headless CLI"]
-  end
-
-  Clerk["Clerk
user identity and OAuth"] - - subgraph Relay["T3 Code Relay on Cloudflare"] - Worker["Relay Worker
HTTP API"] - Queue["APNs delivery queue
with dead-letter queue"] - Hyperdrive["Hyperdrive"] - CF["Cloudflare tunnel + DNS APIs"] - end - - DB["PlanetScale Postgres
prod database + stage branches"] - APNs["Apple Push Notification service"] - Traces["Axiom OTLP traces"] - - subgraph Local["Linked T3 environment"] - Env["Environment server
local auth authority"] - RelayClient["Relay client
cloudflared implementation"] - Secrets["Server secret store
keys + relay config"] - end - - Endpoint["Managed HTTPS/WSS endpoint"] - - Web -->|"Clerk bearer / relay DPoP"| Worker - Mobile -->|"Clerk bearer / relay DPoP"| Worker - CLI -->|"Clerk OAuth bearer"| Worker - Web --> Clerk - Mobile --> Clerk - CLI -->|"PKCE authorization code flow"| Clerk - Worker --> Hyperdrive --> DB - Worker --> Traces - Worker --> CF - Worker --> Queue --> APNs - Worker --> Endpoint - Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env - Env --> Secrets - Web -->|"environment token + DPoP
HTTPS / WSS"| Endpoint - Mobile -->|"environment token + DPoP
HTTPS / WSS"| Endpoint - Env -->|"environment bearer + signed activity proof"| Worker -
-
- -
-

Authentication and Transport Matrix

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BoundaryTransportAuthenticationPurpose
Web or mobile -> relay link managementHTTPSClerk session-template bearer tokenCreate a link challenge, submit an environment proof, list links, or unlink.
CLI -> relay link managementHTTPSClerk OAuth access token obtained with PKCEReconcile the desired headless cloud link when the server starts.
Web or mobile -> relay protected client endpointsHTTPSRelay-issued DPoP token plus per-request DPoP proof - Check environment status, request a remote connection, and register mobile devices. -
Relay -> managed environment endpointHTTPS over the managed tunnelShort-lived relay-signed JWT request proof - Request a signed health response or ask the local environment to mint a credential. -
Environment -> relay activity publicationHTTPSRelay-issued environment bearer credential plus environment-signed JWT proof - Publish redacted agent-activity state for push notifications and Live Activities. -
Remote client -> environmentHTTPS and WSS over the managed tunnelEnvironment-issued access token bound to the client DPoP keyUse the normal environment HTTP APIs and request a one-time WebSocket ticket.
-
- -
-

Credential Ownership

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CredentialCreated byStored byUse
Clerk session-template JWTClerkWeb or mobile client - Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange. -
CLI OAuth access and refresh tokenClerk OAuthLocal server secret storeAuthorize headless CLI link reconciliation.
Environment Ed25519 key pairLocal environment serverLocal server secret store - Sign link proofs, health responses, mint responses, and activity publication proofs. -
Relay cloud-mint key pairRelay deploymentPrivate key in relay Worker config; public key installed into the environmentSign relay-to-environment health and mint requests.
Relay environment bearer credentialRelayHashed in relay Postgres; plaintext in local server secret storeAuthenticate environment-to-relay agent-activity publication.
Relay DPoP access tokenRelayRemote client memory cache - Authorize status, connect, and mobile registration endpoints with proof of - possession. -
Environment bootstrap credentialLocal environment serverPassed relay -> remote clientOne-time exchange at the environment /oauth/token endpoint.
Environment access tokenLocal environment serverRemote clientAuthorize environment HTTP operations and WebSocket ticket issuance.
WebSocket ticketLocal environment serverRemote client until consumedShort-lived one-time credential for the WSS upgrade.
Relay client connector tokenCloudflare tunnel APILocal server secret store - Start the local relay client. It is passed through TUNNEL_TOKEN, not a - shell argument. -
-
- -
-
-

Flow 1: Link From the Desktop Web UI

-
-sequenceDiagram
-  participant U as Signed-in user
-  participant C as Desktop web UI
-  participant E as Local environment
-  participant R as Relay Worker
-  participant CF as Cloudflare APIs
-  participant RC as Local relay client
-
-  U->>C: Enable T3 Connect for a locally paired environment
-  C->>E: Check relay client availability
-  alt relay client is missing
-    C->>U: Prompt before download and install
-    U->>C: Confirm
-    C->>E: Stream relay-client install RPC
-    E-->>C: Progress stages
-  end
-  C->>R: POST /v1/client/environment-link-challenges (Clerk bearer)
-  R-->>C: Short-lived challenge
-  C->>E: POST /api/connect/link-proof (local relay:write bearer)
-  E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin
-  E-->>C: Environment-signed link proof
-  C->>R: POST /v1/client/environment-links (Clerk bearer + proof)
-  R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin
-  R->>CF: Reconcile tunnel, ingress, and CNAME
-  R->>R: Upsert user/environment link and issue environment bearer
-  R-->>C: Managed endpoint, relay config, and connector token
-  C->>E: POST /api/connect/relay-config (local relay:write bearer)
-  E->>RC: Start relay client with TUNNEL_TOKEN
-          
-
- The local link-proof endpoint requires relay:write, rejects forwarded - authority headers, and accepts only an exact loopback origin. Environment keypair - persistence is atomic across concurrent link attempts. -
-

- A locally paired mobile client uses the same relay challenge, local proof, relay link, - and local relay-config endpoints. The desktop web UI is the surface that checks relay - client availability and offers the managed install dialog. -

-
- -
-

Flow 2: Headless CLI Link

-
-sequenceDiagram
-  participant U as Operator
-  participant CLI as t3 connect CLI
-  participant Clerk as Clerk OAuth
-  participant S as Environment server on next start
-  participant R as Relay Worker
-  participant RC as Local relay client
-
-  U->>CLI: t3 connect link
-  CLI->>CLI: Resolve relay client
-  alt relay client is missing
-    CLI->>U: Confirm managed relay-client install
-    U->>CLI: Confirm
-    CLI->>CLI: Install with terminal progress updates
-  end
-  CLI->>Clerk: Browser PKCE authorization-code flow
-  Clerk-->>CLI: OAuth access and refresh tokens
-  CLI->>CLI: Persist desired cloud-link state
-  U->>S: Start T3 environment server
-  S->>R: Create challenge and submit local signed proof
-  R-->>S: Managed endpoint config and connector token
-  S->>RC: Start relay client
-          
-

- t3 connect login stores authorization without enabling exposure. - t3 connect unlink disables exposure while retaining authorization. - t3 connect logout also removes the stored CLI authorization. -

-
- -
-

Flow 3: Remote Connect Bootstrap

-
-sequenceDiagram
-  participant C as Web or mobile client
-  participant Clerk as Clerk
-  participant R as Relay Worker
-  participant E as Managed environment endpoint
-
-  C->>Clerk: Get Clerk session-template JWT
-  C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof)
-  R-->>C: Relay DPoP access token
-  C->>R: POST /v1/environments/:id/connect (relay DPoP)
-  R->>E: POST /api/t3-connect/mint-credential (relay-signed proof)
-  E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards
-  E-->>R: Bootstrap credential + environment-signed proof
-  R->>R: Verify environment signature, nonce, endpoint, and DPoP binding
-  R-->>C: Managed endpoint + bootstrap credential
-  C->>E: POST /oauth/token (bootstrap credential + DPoP proof)
-  E-->>C: Environment DPoP-bound access token
-  C->>E: POST /api/auth/websocket-ticket
-  E-->>C: One-time WebSocket ticket
-  C->>E: WSS /ws with ticket
-          
-
- Relay-to-environment control requests disable redirects, use only a reconciled managed - endpoint allocation, and require a signed environment response before the relay returns - data to the client. -
-
- -
-

Flow 4: Agent Activity Notifications

-
-sequenceDiagram
-  participant E as Local environment
-  participant R as Relay Worker
-  participant DB as Relay Postgres
-  participant Q as APNs queue
-  participant APNs as Apple Push Notification service
-  participant M as Mobile app
-
-  E->>E: Project publishable thread state and redact failure detail
-  E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity
-  Note over E,R: Environment bearer credential + environment-signed per-state proof
-  R->>R: Verify credential, signature, scope, expiry, and nonce
-  R->>DB: Store current activity row
-  R->>Q: Enqueue push or Live Activity delivery jobs
-  Q->>APNs: Deliver signed APNs request
-  APNs-->>M: Push notification or Live Activity update
-          
-

- The local server publishes a deliberately narrow projection. Failed runs use a fixed - redacted summary, and detail strings are capped before publication. -

-
-
- -
-
-

Managed Endpoint Reconciliation

-

- Managed endpoint resources are keyed by (userId, environmentId). The relay - reserves a stable hostname and tunnel name before provisioning, then checkpoints the - tunnel ID, DNS record ID, and ready state in Postgres. -

-
-
- 1. Reserve deterministic allocation - Hostname and tunnel name derive from relay stage, cloud user, and environment ID. -
-
- 2. Reuse or create tunnel - Existing named tunnels are reused. Tunnel ingress is rewritten to the validated - loopback origin. -
-
- 3. Reconcile DNS - Existing CNAMEs are updated and duplicate records are removed. Create conflicts are - recovered by listing and reconciling the winning record. -
-
- 4. Mark ready - Connect and status requests use only ready allocations whose hostname still matches - the relay-managed namespace. -
-
-
- -
-

Unlink Cleanup

-

- Relay unlink removes the user's managed endpoint allocation before revoking the - user/environment link: -

-
    -
  • delete the managed DNS record if present;
  • -
  • delete the Cloudflare tunnel if present;
  • -
  • remove the allocation row;
  • -
  • revoke the user's environment link; and
  • -
  • - revoke the environment publication credential only after the final active link for - that environment key disappears. -
  • -
-

- Local unlink stops the relay client and removes the locally persisted relay URL, issuer, - linked user, environment publication credential, cloud-mint public key, connector - config, and agent-activity preference. -

-
-
- -
-

Cloudflare Tunnel Operational Profile

-

- Managed tunnels are private application backends, not general-purpose public hosting. The - relay provisions one named Cloudflare tunnel and one DNS record per active - (userId, environmentId) allocation. -

-
    -
  • - Lifecycle: the tunnel is created when an environment is linked, reused - across retry-safe reconciliation, kept for the lifetime of that link, and deleted on - unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried. -
  • -
  • - Origin: tunnel ingress is restricted to the linked environment server's - validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are - rejected. -
  • -
  • - Protocols: managed endpoints carry HTTPS request/response traffic and - WSS connections only. Remote clients use the normal environment HTTP APIs and - authenticated WebSocket RPC transport through the tunnel. -
  • -
  • - Relay-generated traffic: the hosted relay sends sparse HTTPS health - checks and credential-mint requests during status checks and remote connection - bootstrap. It does not proxy steady-state remote sessions. -
  • -
  • - Traffic shape: expected load is interactive and user-driven. Active - remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates; - inactive linked environments should carry little or no tunnel traffic. -
  • -
  • - Capacity planning: the project is pre-launch, so there is not yet a - production bandwidth baseline. Tunnel count grows with active linked user/environment - allocations, while bandwidth and concurrent WebSockets grow with active remote use. -
  • -
-
- -
-

SSRF and Tunnel Hardening

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RiskImplemented control
A client asks the relay to tunnel an arbitrary host. - The environment and relay both accept managed tunnel origins only for loopback hosts - and valid ports. -
A forwarded request tricks local origin validation. - The local link-proof handler requires an exact loopback request URL and rejects - forwarded host or protocol headers. -
A stored endpoint is replaced with an arbitrary external URL. - Connect and status resolve a ready allocation from Postgres and require a hostname - under the configured managed endpoint zone. -
A managed endpoint redirects relay egress elsewhere. - Relay-to-environment status and mint requests set redirect handling to - manual. -
A process behind the tunnel returns attacker-controlled data. - The relay verifies environment-signed health and mint responses against the linked - environment public key, expected nonce, and request binding. -
A stolen remote access token is replayed. - Relay and environment access tokens are DPoP-bound; per-request proofs and replay - guards are required. -
The relay client token leaks through process listings or shell parsing. - The relay client is spawned without a shell and receives its connector token in - TUNNEL_TOKEN. -
-
- Managed tunnel hostnames live under the configured tunnel DNS zone. Serving them from a - dedicated registrable domain with a Public Suffix List entry remains an operational - isolation requirement before broad untrusted use. -
-
- -
-

Relay HTTP API

-

- The relay publishes an OpenAPI document at /openapi.json, interactive API - docs at /docs, and redirects / to the docs. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EndpointAuthenticationPurpose
GET /healthNoneRelay health check.
GET /.well-known/oauth-authorization-serverNoneRelay OAuth token-exchange discovery metadata.
GET /.well-known/oauth-protected-resourceNoneRelay DPoP protected-resource discovery metadata.
GET /v1/environmentsClerk bearerList environments linked to the signed-in user.
POST /v1/client/environment-link-challengesClerk bearerCreate a short-lived user-bound environment link challenge.
POST /v1/client/environment-linksClerk bearer - Verify a local environment proof, reconcile a managed endpoint, and upsert the user - link. -
DELETE /v1/client/environment-links/:environmentIdClerk bearerRemove managed endpoint resources and revoke the user's link.
POST /v1/client/dpop-tokenClerk token in token-exchange payload plus DPoP proofIssue a relay DPoP access token for the requested supported scopes.
POST /v1/environments/:environmentId/statusRelay DPoPRequest and validate a signed environment health response.
POST /v1/environments/:environmentId/connectRelay DPoPRequest and validate an environment bootstrap credential.
POST /v1/mobile/devicesRelay DPoPRegister or update a mobile device for notifications.
POST /v1/mobile/live-activitiesRelay DPoPRegister a Live Activity push token.
DELETE /v1/mobile/devices/:deviceIdRelay DPoPUnregister a mobile device.
- POST /v1/environments/:environmentId/threads/:threadId/agent-activity - Environment bearer plus signed publish proofPublish redacted current agent activity for notification delivery.
-
- -
-

Environment Cloud HTTP API

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EndpointAuthenticationPurpose
POST /api/connect/link-proofLocal environment token with relay:writeValidate local origin and sign an environment link proof.
POST /api/connect/relay-configLocal environment token with relay:writePersist linked relay configuration and start the relay client.
GET /api/connect/link-stateLocal environment token with relay:readRead the environment's current T3 Connect link state.
POST /api/connect/preferencesLocal environment token with relay:writeEnable or disable agent-activity publication.
POST /api/connect/unlinkLocal environment token with relay:writeStop the relay client and clear local T3 Connect configuration.
POST /api/t3-connect/healthRelay-signed proofReturn a signed environment health response.
POST /api/t3-connect/mint-credentialRelay-signed proofMint a DPoP-bound one-time bootstrap credential for a remote client.
POST /oauth/tokenBootstrap credential plus DPoP proofExchange a bootstrap credential for an environment access token.
POST /api/auth/websocket-ticketEnvironment access tokenIssue a one-time ticket for the WSS upgrade.
-
- -
-
-

Relay Client Installation

-

- Desktop web UI availability checks and installs are local WebSocket RPC methods: - cloud.getRelayClientStatus and streaming - cloud.installRelayClient. The install stream emits the current stage: - checking, waiting for lock, downloading, verifying, installing, validating, and - activating. -

-

- The web UI asks for confirmation before installation and displays a custom progress - dialog. The CLI invokes the same local relay-client service directly and reports the - same progress stages in the terminal. -

-
- -
-

Relay Infrastructure

-
    -
  • Cloudflare Worker for the hosted relay HTTP API.
  • -
  • PlanetScale Postgres database named t3coderelay.
  • -
  • - Production uses the shared database; non-production stages use database branches. -
  • -
  • - Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit. -
  • -
  • Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.
  • -
  • Cloudflare APNs delivery queue plus dead-letter queue.
  • -
  • A five-minute cron job that prunes expired DPoP replay rows.
  • -
  • Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.
  • -
-
-
- -
-

Deployment Configuration

- - - - - - - - - - - - - - - - - - - - - -
BoundaryConfiguration
Relay infrastructure - RELAY_API_ZONE_NAME, RELAY_TUNNEL_ZONE_NAME, optional - RELAY_DOMAIN, CLERK_PUBLISHABLE_KEY, - CLERK_SECRET_KEY, CLERK_JWT_AUDIENCE, and APNs - credentials. -
Source-built desktop/server cloud features - Optional T3CODE_RELAY_URL, T3CODE_CLERK_PUBLISHABLE_KEY, - and T3CODE_CLERK_CLI_OAUTH_CLIENT_ID. Release builds inject public - values at build time. -
Web and mobile cloud clients - Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs - must normalize to an absolute HTTPS origin without credentials, query, fragment, or - non-root path. -
-
- -
-

Implementation Checklist

-
    -
  • - Keep the relay cloud-mint private key hosted-only; install only its public key into - environments. -
  • -
  • - Keep environment signing private keys local and persist keypair creation atomically. -
  • -
  • - Require a local relay:write session before linking or changing relay - config. -
  • -
  • Validate relay URLs as secure absolute HTTPS origins before sending credentials.
  • -
  • Require loopback-only tunnel origins on both sides of the link-proof boundary.
  • -
  • - Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs. -
  • -
  • Disable redirects on relay-to-environment requests.
  • -
  • Validate environment-signed response proofs before consuming tunneled responses.
  • -
  • Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.
  • -
  • - Revoke shared environment publication credentials only after the final active link - disappears. -
  • -
  • Redact and cap local agent failure details before publishing them externally.
  • -
-
- -
-

Standards References

-
    -
  • - RFC 8252: browser-based OAuth for - native apps, used by the CLI PKCE authorization flow. -
  • -
  • - RFC 8693: OAuth token exchange, - used for Clerk-token to relay-DPoP-token exchange. -
  • -
  • - RFC 9449: DPoP proof of possession, - used for relay and environment access tokens. -
  • -
-
-
- - - diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index 6f796123e98b..bf02eb3538be 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -1,273 +1,81 @@ # T3 Connect -> For maintainers. Using T3 Code? See [docs/user](../user/). - -T3 Connect uses one Clerk application for web, desktop, and mobile authentication. The relay verifies -two kinds of bearer credential: template JWTs generated from the `t3-relay` template with the shared -`t3-code-relay` audience, and Clerk OAuth tokens issued to the CLI. `verifyRelayClientBearerToken` in -`infra/relay/src/http/Api.ts` tries the template/session path first and falls back to OAuth -verification (`acceptsToken: "oauth_token"`), so the CLI's OAuth credential works without a JWT -template. - -For the wider system diagram, see -[t3-code-connect-auth-flow.html](./t3-code-connect-auth-flow.html). - -## Application Keys - -T3 Connect is disabled in a fresh clone. To enable it for source builds against the production -deployment, copy the repository-root example file: - -```sh -cp .env.example .env -``` - -`.env.example` carries the production public identifiers (the same values baked into official -release builds). To target a different Clerk application or relay, set the values yourself in a -repository-root `.env` or `.env.local` file: - -```dotenv -T3CODE_CLERK_PUBLISHABLE_KEY= -T3CODE_CLERK_JWT_TEMPLATE= -T3CODE_CLERK_CLI_OAUTH_CLIENT_ID= -T3CODE_RELAY_URL=https://relay.example.com -``` - -The shared client loader projects these canonical values into framework-specific `VITE_*` and -`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new -client configuration should use the canonical names. - -Configuration precedence is: - -1. Process or CI environment variables. -2. Repository-root `.env.local`. -3. Repository-root `.env`. - -The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public -identifiers, not secrets. -Web, desktop, mobile, and bundled server builds statically inject the values they consume during -their build step. A built artifact does not need an environment file at runtime. CI release builds -should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, -`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and -production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS -environment. - -When any client-facing public value is absent, cloud UI is omitted. The `t3 connect` command group is -always registered: when the CLI public values are absent, `makeCli` in `apps/server/src/bin.ts` -registers a hidden fallback `connect` command that reports the missing configuration instead of -silently vanishing from help. The bundled server still accepts runtime overrides for self-hosted or -operator-managed deployments. - -For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay -deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`, -`CLERK_PUBLISHABLE_KEY`, and `CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in -deployment defaults. -`vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads -`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env` -with the deployed HTTPS relay URL. The relay still requires -`CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application -environment or commit it to the repository. - -The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference -that database and provision isolated PlanetScale branches, so deploy `prod` before creating a -personal developer stage. - -## Headless CLI OAuth Application - -The `t3 connect` commands authorize a headless environment with a separate Clerk OAuth application. -This uses an OAuth public client with PKCE, so the CLI stores no client secret. - -In **Clerk Dashboard > OAuth applications**: - -1. Create an OAuth application for the T3 CLI. -2. Enable the **Public** option so authorization-code exchange uses PKCE. -3. Add **both** allowed redirect URIs: - - `http://127.0.0.1:34338/callback` for the loopback listener; - - `https://app.t3.codes/connect/callback` for the hosted out-of-band flow. This is - `connectCallbackUrl(DEFAULT_HOSTED_APP_URL)` from `packages/shared/src/connectAuth.ts`, so a - custom `T3CODE_HOSTED_APP_URL` means `$T3CODE_HOSTED_APP_URL/connect/callback` instead. - Omitting it breaks headless and SSH authorization. -4. Enable the `openid`, `profile`, and `email` scopes. -5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build - environment to the generated public client ID. - -Both CLI flows start at the hosted `/connect` page (`buildConnectAuthorizeRequestUrl` in -`packages/shared/src/connectAuth.ts`), which waits for a Clerk session and then forwards the request -to Clerk's `/oauth/authorize`. The CLI never opens `/oauth/authorize` directly: a signed-out browser -sent there goes through Clerk's sign-in redirect, which drops the authorize query parameters and -fails the flow with `unsupported_response_type` or an empty `state` (#5051). The loopback flow marks -the request with a `port` fragment parameter so the hosted page asks Clerk to redirect the -authorization code straight to `http://127.0.0.1:/callback`; the out-of-band flow omits it and -uses the hosted `/connect/callback` page instead. The CLI derives Clerk's frontend API URL from the -publishable key and calls only the `/oauth/token` endpoint directly. The relay is not involved in -the OAuth handshake; it only validates the issued Clerk bearer token when the CLI manages an -environment link. - -The connect command group is: - -```sh -t3 connect # default: onboarding -t3 connect login -t3 connect link # --publish-only -t3 connect status # --json -t3 connect publish # --disable -t3 connect unlink -t3 connect logout -``` - -`t3 serve` is a separate top-level command, not a connect subcommand. - -`t3 connect login` opens the Clerk authorization flow and stores the CLI credential without enabling -cloud exposure. `t3 connect link` installs the pinned managed `cloudflared` binary when needed, -authorizes when needed, and records durable intent to expose the environment. It works without a -running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the -managed tunnel. `t3 connect unlink` records disabled intent immediately, stops a reachable running -connector, and attempts to revoke the relay-side environment record. It retains the stored CLI -authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect -logout` performs the same cleanup and removes the stored CLI authorization. - -The background service has an independent lifecycle. Connect setup may offer to install it, but -logout leaves it running; manage it with `t3 service status`, `install`, `update`, and `uninstall`. - -### Headless and SSH authorization - -The loopback OAuth callback listener binds to port `34338`. That path only works when a browser on -the same machine can reach it, so `authorizeCli` in `apps/server/src/cli/connect.ts` automatically -selects the out-of-band flow when `--headless` is passed or when it detects SSH through -`SSH_CONNECTION` or `SSH_TTY`. The out-of-band flow prints the hosted `/connect` authorization URL -and accepts a pasted authorization code, so no port is involved. - -Port forwarding is therefore optional, not required. Forward the port only if you specifically want -the loopback flow over SSH: - -```sh -ssh -L 34338:127.0.0.1:34338 -``` - -## JWT Template - -In **Clerk Dashboard > JWT templates**, create a template with: - -| Setting | Value | -| ------- | ---------------------------- | -| Name | `t3-relay` | -| Claims | `{ "aud": "t3-code-relay" }` | - -Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set -`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and -`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value -is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still -selects the concrete relay deployment, but changing that URL does not require a JWT template change. - -## Desktop OAuth Redirect Allowlist - -The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme. -In **Clerk Dashboard > Native applications**, enable the Native API and add these entries under the -mobile SSO redirect allowlist: - -```text -t3code-dev://app/ -t3code://app/ -``` - -Local desktop development uses `t3code-dev://app`, while packaged builds use `t3code://app`. Add the -matching origin to each Clerk instance's Backend API `allowed_origins` array as well. The development -Clerk instance should only need `t3code-dev://app`; the production Clerk instance should only need -`t3code://app`. `@clerk/electron` owns the native request adapter, encrypted Clerk token persistence, -external-browser OAuth transport, and callback delivery for initial sign-in and linked-account flows. - -There is currently no Dashboard UI for `allowed_origins`. Preserve any existing entries and update -the instance through the Backend API: - -```sh -curl -X PATCH https://api.clerk.com/v1/instance \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $CLERK_SECRET_KEY" \ - -d '{"allowed_origins":["t3code://app"]}' -``` - -Never put `CLERK_SECRET_KEY` in the desktop app, a client-facing environment file, or a build -artifact. - -## Desktop Passkeys - -The production macOS bundle ID is `com.t3tools.t3code`. To enable native passkeys: - -1. Create an explicit macOS App ID for `com.t3tools.t3code` in the Apple Developer portal and enable - **Associated Domains**. -2. Create a compatible macOS provisioning profile for that App ID and the certificate used to sign - the distributed app. -3. In Clerk's Native API settings, add an iOS app with the same Apple Team ID and bundle ID. This is - also the configuration point for Electron/macOS passkeys. -4. Confirm Clerk serves `https:///.well-known/apple-app-site-association` and that - `webcredentials.apps` contains `.com.t3tools.t3code`. -5. Set the local or CI signing configuration described below. - -For a local signed build, add these values to `.env.local` or export them before invoking the -desktop artifact command: - -```dotenv -T3CODE_APPLE_TEAM_ID=ABC1234567 -T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile -# Optional: comma-separated override when Clerk's RP ID differs from the Frontend API hostname. -T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com -``` - -When `T3CODE_CLERK_PASSKEY_RP_DOMAINS` is absent, the build derives the RP domain from -`T3CODE_CLERK_PUBLISHABLE_KEY`. Signed macOS builds fail early if the Team ID, provisioning profile, -or RP-domain configuration is missing. The generated main-app entitlements include every configured -`webcredentials:` entry; helper apps keep Electron's minimal default entitlements. - -The normal `dev:desktop` launcher is unsigned and cannot complete macOS passkey ceremonies. For -renderer HMR, build and install a signed app first, run the renderer dev server, then launch the -installed app executable with `VITE_DEV_SERVER_URL` and `T3CODE_PORT` set. Rebuild the signed app -after native dependency, main-process, preload, entitlement, provisioning, or signing changes; -renderer-only changes can reuse the installed app. - -For the default development ports, run `pnpm dev:web` in one terminal and launch the installed -binary from another: - -```sh -VITE_DEV_SERVER_URL=http://127.0.0.1:5733 \ -T3CODE_PORT=13773 \ - "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)" -``` - -After changing Associated Domains, bump the build version before rebuilding; macOS may otherwise -reuse stale Shared Web Credentials metadata for the same app/version pair. - -Verify the installed bundle before testing: - -```sh -codesign --verify --deep --strict "/Applications/T3 Code (Alpha).app" -codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app" -``` - -The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth -flow uses a custom redirect URI, add that exact URI to the same allowlist. - -## Sign-in Surfaces - -Signed-in users manage T3 Connect under **Connections**. The settings sidebar also has dedicated -controls, rendered by `SettingsSidebarNav.tsx`: `T3ConnectSidebarSignIn` in the footer shows a -**Sign in to T3 Connect** button while signed out, and `T3ConnectSidebarAvatar` shows a Clerk -`UserButton` account control while signed in. Both are gated on cloud public configuration. -Desktop renders the same web bundle, so it has them too. The waitlist enrollment flow from the -private beta was removed when Connect went GA; sign-up is open unless a Clerk restriction below is -enabled. - -## Restricting Sign-ups: Known-User Allowlist - -For a closed deployment where all permitted users are known in advance, restrict sign-up to -permitted email addresses or domains: - -1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email - domain. -2. Enable the allowlist and save. -3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or - manually created. - -Do not enable an empty allowlist: it blocks all new sign-ups. - -Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud -access. To remove an already-created user's access, ban that user in Clerk so their active -sessions are ended and future sign-ins are rejected. +T3 Connect uses Clerk for cloud identity. The relay manages environment links, +credentials for reaching environments, and managed tunnel allocations. After +bootstrap, clients send application traffic through the environment's tunnel +hostname; the relay Worker does not proxy their HTTP or WebSocket sessions. + +Clerk, deployment, and native authentication setup live in the +[Connect setup runbook](../operations/connect-setup.md). + +## The relay is a trusted broker + +An authenticated cloud user still needs an active environment link. The relay +asks that environment to mint a one-time bootstrap credential bound to the +client's DPoP key. The client exchanges it directly with the environment for an +[environment session](./environment-auth.md). The relay never receives that +session token, and possessing the bootstrap credential alone does not permit +redeeming it without the client's private key. + +Both sides authenticate this exchange. The environment accepts only bounded, +replay-guarded relay proofs for its own identity, linked user, and requested +operation. Signed environment responses bind the result to the request nonce; +mint responses also bind the credential to the client proof key. The relay +verifies those bindings before returning a credential. This prevents a different +process behind the tunnel from impersonating the linked environment. The checks +meet in the +[environment cloud handlers](../../apps/server/src/cloud/http.ts) and +[relay connector](../../infra/relay/src/environments/EnvironmentConnector.ts). + +The relay holds the signing authority for mint requests. DPoP protects an honest +exchange from credential reuse; it does not make a compromised relay signing +key harmless. Keep that trust assumption explicit when changing the protocol. + +Managed tunnels expose only a validated loopback HTTP origin. Link proof checks +reject forwarded authority headers, and the relay resolves endpoints from its +own managed allocations rather than a caller-supplied URL. Health and mint +requests must not follow redirects. These restrictions keep endpoint discovery +from turning into arbitrary relay egress or exposing another service on the +environment host. + +## A link outlives a connector process + +CLI authorization, desired exposure, and a running connector have different +lifetimes. Linking can record intent while the server is stopped. Startup +reconciles that intent. CLI logout removes the stored cloud credential and +disables exposure without uninstalling the environment's background service. + +Managed allocations belong to a user/environment pair. Provisioning checkpoints +external tunnel and DNS resources so retries can reconcile partial work. A +normal shutdown of a CLI-managed link releases its tunnel to avoid paying for +an idle resource, retaining the hostname reservation for the next startup. +It also retains the allocation record so the environment remains "offline" +rather than becoming "not authorized". + +Two cases must retain the tunnel across shutdown. A link installed through a +client has no startup provisioning path and depends on its stored connector +token. An update handoff immediately starts a replacement server, and replacing +the tunnel would add routing propagation delay to every update. These exceptions +belong to [shutdown handling](../../apps/server/src/cloud/http.ts). + +Release and unlink claim the allocation generation before deleting external +resources. A delayed cleanup must not delete a tunnel reused by a concurrent +restart or relink. Unlink commits authorization revocation before external +teardown, because a database failure must leave the active link usable. Failed +teardown retains enough state to retry. See the +[managed endpoint lifecycle](../../infra/relay/src/environments/ManagedEndpointProvider.ts). + +## OAuth traps + +Interactive clients and the headless CLI use the same Clerk application but +different credentials. The relay accepts both session-template JWTs and CLI +OAuth tokens; requiring a JWT template for the CLI would reject valid logins. +The CLI is a public OAuth client using PKCE and stores no client secret. + +CLI authorization starts on the hosted `/connect` page so sign-in completes +before entering Clerk's authorize endpoint. Sending a signed-out browser +straight to that endpoint loses the authorize parameters during the sign-in +redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) preserves +PKCE and state for both loopback and pasted-code callbacks. SSH and headless +sessions use the pasted-code flow because the browser cannot ordinarily reach a +listener on the remote machine. diff --git a/docs/internals/terminal-runtime.md b/docs/internals/terminal-runtime.md index addfa1ce093a..ca5f0d46ead6 100644 --- a/docs/internals/terminal-runtime.md +++ b/docs/internals/terminal-runtime.md @@ -1,52 +1,44 @@ # Terminal runtime -The environment server owns terminal processes, retained output history, and -session lifecycle. Web, desktop, and mobile clients attach to the same -server-owned session over the environment RPC connection. The desktop renderer -does not own a separate PTY. Clients can reconnect to a running PTY or share -it with another client. - -## Output path - -PTY output follows this path: - -```text -PTY callback - -> ordered process-event drain - -> bounded retained-history append - -> live terminal output event - -> coalesced history persistence -``` - -Live output events contain only the new PTY data. Full retained history is -materialized only when the server returns a snapshot or when the coalescing -persistence worker writes the latest state. - -Retained history is limited to 5,000 lines and 8 MiB of UTF-8 text per terminal. -The server discards the oldest output when either limit is reached. A byte -cutoff can shorten the oldest retained line, but does not split a Unicode code -point. Live output is not truncated. - -History uses small chunks with byte and newline counts. Appending output scans -the new text and any removed chunk prefix, not the full retained history. -Empty lines, incomplete final lines, and trailing newlines remain unchanged -within the limits. Split surrogate pairs are joined before byte eviction. - -Discard each chunk's string reference as soon as it leaves retained history. -Array compaction can run later. Shared web and mobile client state retains at -most 512 KiB, so each client can display less scrollback than the server keeps. - -Measure sustained-output changes against a full retained history so terminal -throughput does not regress unnoticed. - -## Persistence - -History persistence is keyed by terminal session and coalesces pending writes. -The worker reads the newest bounded-history state after its debounce instead -of receiving a newly materialized full string for every PTY callback. Clear, -restart, close, and final flush operations still force the latest state to -disk before their lifecycle boundary completes. - -Restoration reads at most the last 8 MiB from current and legacy history files. -It skips an incomplete UTF-8 code point at the start and applies the line limit -before rewriting oversized files. File handles close before that rewrite. +The environment server owns PTYs, session lifetime, and retained output. Every +client, including the desktop renderer, attaches through the environment connection. +This lets clients reconnect or share a running session. Renderer choices stay local +to each client and do not change terminal contracts. + +## Output and retention + +[Terminal history](../../apps/server/src/terminal/Manager.ts) is incremental. +PTY callbacks append new chunks; live events carry only those chunks. Materializing +or copying full scrollback on every callback makes output cost grow with retained +history, so snapshots and coalesced persistence are the materialization boundaries. +Persistence queues the mutable history buffer and reads its latest value when the +write runs. Clear, restart, and close must drain writes before completing their +lifecycle boundary. + +Server history is capped at 5,000 lines and 8 MiB of UTF-8 text per terminal, so a +long unterminated line cannot bypass retention. Eviction removes the oldest output +without splitting Unicode code points; live output is not truncated. Release +discarded chunk references immediately, even if array compaction happens later. +Client buffers have a separate 512 KiB cap. Measure throughput with full scrollback +when changing this path. + +Restoration must read only the bounded tail of current or legacy history files, +skip any incomplete UTF-8 prefix, and apply the line limit. Close the read handle +before rewriting the capped file. Reading whole old logs would defeat the memory +bound during startup. + +## Renderer ownership + +Android and web use the same `libghostty-vt` C ABI for terminal behavior. Platform +adapters own drawing and input integration, and React stays out of terminal frames. +The web adapter shares one WebAssembly instance per browser tab while each terminal +owns and frees its own handles. The canonical upstream pin is +[`native/libghostty-vt/VERSION`](../../native/libghostty-vt/VERSION); both native and +web artifacts must be rebuilt when it changes. Web embeds the revision in its build +info so the ABI check can detect drift without a second pin. + +Restoring scrollback must not send terminal replies to the current shell. Historical +device queries can otherwise provoke fresh replies that appear as junk at the +prompt. The server strips query/response traffic from retained history, and the +[web renderer](../../apps/web/src/terminal/ghostty/core.ts) detaches its PTY writer +during replay. Preserve both protections when changing retention or renderer code. diff --git a/docs/internals/voice-input.md b/docs/internals/voice-input.md index dd37f2a86957..55e3007a4872 100644 --- a/docs/internals/voice-input.md +++ b/docs/internals/voice-input.md @@ -1,102 +1,20 @@ # Voice input -> For maintainers. Using T3 Code? See [voice input on iPhone](../user/composer.md#voice-input-on-iphone). - -Voice input produces editable composer text. The current implementation records on the client and -transcribes locally with Apple's `SpeechAnalyzer` and `SpeechTranscriber` on supported iOS 26+ -devices. Environment-provided transcription and transcription on web and desktop are not implemented. - -## Current boundaries - -The shared [`VoiceInputController`][controller] in `packages/client-runtime` owns preparation, -recording, transcription, cancellation, temporary-file cleanup, and insertion into the captured -draft selection. Applications import it through the [voice-input entry point][voice-input] as -`@t3tools/client-runtime/voice-input`. Its dependencies separate capture from transcription; the -controller imports neither React Native nor an Apple transcription API. - -The shared [transcription contract][transcription] defines `VoiceTranscriber`, -`PreparedVoiceTranscription`, and transcription errors. The controller calls `getTranscriber()` once -at the start of an operation, before asking for microphone permission. Preparation returns a resolved -locale and a bound `transcribe` function. The controller retains that result for the recording, so a -selection change cannot prepare with one implementation and transcribe with another. - -[`useVoiceInputController`][hook] supplies Expo audio capture, microphone permissions, audio-session -management, waveform samples, and app and navigation lifecycle handling. It normalizes Expo's -`mediaServicesDidReset` into a generic recorder error. [`voiceTranscription.ios.ts`][ios] adapts -`@react-native-ai/apple` through `getLocalVoiceTranscriber()`, capturing the requested device locale -and binding the prepared transcriber to Apple's resolved locale. The other-platform binding returns -no local transcriber. That result describes the local implementation, not whether a client could use -an environment's transcription service. - -Mobile's [`voiceInputPresentation.ts`][presentation] maps shared state to toolbar labels and actions. -Waveform and toolbar rendering stay in mobile. The composer edits draft text without selecting a -speech vendor. -Recording captures the draft owner, revision, text, and selection. A late transcript cannot overwrite -a different or edited draft. Only normal message submission sends the resulting text to an agent. - -Each operation passes one `AbortSignal` through preparation and transcription. Cancellation -invalidates the operation and aborts that signal immediately. Implementations settle their promises -only after their underlying work stops. The Apple binding checks cancellation between asynchronous -steps but cannot interrupt an in-flight native request. The controller retains its session until -that work settles, ignores its result, and cleans up the recording. - -## Ownership decisions - -The extension boundary distinguishes transcription on the client device from transcription through -the composer's environment. These constraints apply when adding selectable transcription services: - -- Local means the client device, regardless of which machine hosts the environment. A device's lack - of local recognition does not prevent it from recording audio for an environment service. -- Remote service configuration and API keys belong to the environment. The environment calls the - external service. Clients receive service identifiers, labels, and availability information, never - credential values. Transcription services are independent of coding-agent `providerInstances`; - selecting OpenAI for transcription does not select Codex for the thread. -- The client owns its transcription preference, scoped by stable `environmentId`. Its choices are - supported local recognition and the services exposed by the composer's environment. A service ID - is meaningful only within that environment. Different clients can make different choices. -- Resolve and capture the environment, selected service, and locale when an operation starts. - Preparation and transcription use the same selection; preference changes affect the next - recording. Capture environment identity explicitly rather than recovering it from a draft key. - Keep the existing draft-owner and revision checks before inserting text. -- If the selected option is unavailable, report that state and let the user choose another option. - A local failure must not silently upload audio, and a disconnected environment must not redirect - a recording to another environment or service. -- Transcription audio is temporary input, separate from durable chat attachments and messages. - Remote adapters need cancellation of upload and transcription where supported, cleanup after - success, failure, or cancellation, and the same protection against late results as local transcription. - -## Existing integration points - -[`ServerSettingsService`][settings] and [`ServerSecretStore`][secrets] provide environment-owned -configuration and secret persistence. Existing settings redaction handles coding-provider environment -variables only. Any transcription credential fields need their own explicit separation and redaction -before settings responses or subscriptions reach client caches. - -[`ExecutionEnvironmentCapabilities`][capabilities] handles version skew. Remote transcription must be -opt-in: a missing transcription capability means unsupported. The authenticated server-config -subscription and [shared environment state][server-state] already distribute configuration per -environment. A transcription service catalog belongs behind that capability and authenticated -boundary. Older servers expose no remote transcription choices. - -The [attachment upload contracts][uploads] and [shared upload operations][attachment-state] provide a -pattern for authorized binary uploads through an environment, including remote connections. Their -existing chat-attachment retention is not a transcription cleanup policy. - -Future service selection and environment requests belong alongside the controller in -`packages/client-runtime`, with wire contracts in `packages/contracts`. Capture and native local -recognition remain client-specific. An environment-backed transcriber implements the same shared -contract, with its environment and service bound when selected. The controller does not own service -credentials, provider SDKs, or transport selection. - -[controller]: ../../packages/client-runtime/src/voice-input/controller.ts -[voice-input]: ../../packages/client-runtime/src/voice-input/index.ts -[transcription]: ../../packages/client-runtime/src/voice-input/transcription.ts -[hook]: ../../apps/mobile/src/features/voice-input/useVoiceInputController.ts -[presentation]: ../../apps/mobile/src/features/voice-input/voiceInputPresentation.ts -[ios]: ../../apps/mobile/src/native/voiceTranscription.ios.ts -[settings]: ../../apps/server/src/serverSettings.ts -[secrets]: ../../apps/server/src/auth/ServerSecretStore.ts -[capabilities]: ../../packages/contracts/src/environment.ts -[server-state]: ../../packages/client-runtime/src/state/server.ts -[uploads]: ../../packages/contracts/src/assets.ts -[attachment-state]: ../../packages/client-runtime/src/state/attachments.ts +Transcription edits a composer draft. It does not submit an agent turn. Audio is +temporary client input, and only normal message submission sends the resulting +text. The current implementation transcribes locally on supported iOS devices; +environment-backed transcription is not implemented. + +The [shared controller](../../packages/client-runtime/src/voice-input/controller.ts) +owns the operation while the client supplies capture and transcription. Preparation +binds the transcriber and resolved locale for the whole recording. Draft ownership, +text, and revision are captured before recording and checked before insertion, so +a late transcript cannot overwrite a draft that was edited or replaced. + +Cancellation invalidates a result immediately, but resources stay owned until the +underlying work settles. Apple's native transcription call cannot be interrupted +once started. Releasing the session or deleting its recording when the abort signal +fires would race that work. The [transcription contract](../../packages/client-runtime/src/voice-input/transcription.ts) +therefore requires implementations to settle only after their work has stopped; +the [Apple binding](../../apps/mobile/src/native/voiceTranscription.ios.ts) checks +cancellation between native calls and discards late results. diff --git a/docs/internals/work-artifacts.md b/docs/internals/work-artifacts.md deleted file mode 100644 index 157a6174507d..000000000000 --- a/docs/internals/work-artifacts.md +++ /dev/null @@ -1,25 +0,0 @@ -# Engineering work artifacts - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -Keep planned work out of the source tree. Code search should return the product as it exists, not a mix of current behavior and abandoned intentions. - -## Current facts belong in the docs - -Put durable architecture, constraints, and operational knowledge in `docs/internals/` or `docs/operations/`. Write these documents in the present tense and update them with the code they describe. - -When the reason for a decision will matter after implementation, record it in the relevant internal document. Use a separate decision record under `docs/internals/` only when the rationale does not fit cleanly beside the current architecture. - -## Planned work belongs in GitHub - -Track active maintainer work in its GitHub issue or project item. The tracking item should state the outcome, constraints, and acceptance criteria, then link the pull requests that implement it. Split large efforts into one durable specification and small work items that can each close independently. - -Close completed items. Update or delete invalidated work before starting the next implementation session. External proposals follow [CONTRIBUTING.md](../../CONTRIBUTING.md) and belong in Ideas discussions rather than issues. - -## Temporary work stays temporary - -Keep agent scratch files, exploratory research, transcripts, and session handoff notes outside the worktree. They are inputs to the work, not project documentation. - -`.plans/` is gitignored as a safety net for legacy tools. Its presence does not make it an accepted project artifact. Pull requests must not add implementation plans or temporary research under another name. - -A pull request records what changed and why. If a fact must survive after the pull request merges, update the relevant documentation. Otherwise, the tracking item and pull request are the record. diff --git a/docs/internals/workspace-layout.md b/docs/internals/workspace-layout.md deleted file mode 100644 index e933e4528915..000000000000 --- a/docs/internals/workspace-layout.md +++ /dev/null @@ -1,63 +0,0 @@ -# Workspace layout - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -A pnpm workspace driven by [vite-plus](https://vite.plus) (`vp`). See [scripts.md](./scripts.md) for -the task commands. - -## apps - -- `apps/server` (`t3`): the execution runtime and the published CLI. Owns orchestration, provider - drivers, checkpointing, VCS, terminals, filesystem access, auth, and the HTTP + WebSocket surface. - Also serves the built web app. -- `apps/web` (`@t3tools/web`): React + Vite UI. Consumes the shared client runtime and adds routing, - components, and web-specific platform layers. -- `apps/desktop` (`@t3tools/desktop`): Electron shell. Supervises a desktop-scoped `t3` backend, - loads the web bundle over the `t3code://` protocol, and owns SSH-managed remote environments. -- `apps/mobile` (`@t3tools/mobile`): Expo/React Native client. Same client runtime composition as - web, different platform layer and UI. -- `apps/marketing` (`@t3tools/marketing`): Astro marketing site. - -## packages - -- `packages/contracts` (`@t3tools/contracts`): shared Effect Schema definitions. RPC group, - orchestration commands/events/read model, auth scopes, environment descriptors, settings. -- `packages/shared` (`@t3tools/shared`): framework-agnostic utilities used by server and clients - (`DrainableWorker`, git and source-control helpers, relay auth and signing, DPoP, semver, logging, - observability, and more). -- `packages/client-runtime` (`@t3tools/client-runtime`): connection lifecycle, authorization, RPC - session, environment registry, and Atom-based domain state shared by web and mobile. See its - [README](../../packages/client-runtime/README.md). -- `packages/ssh` (`@t3tools/ssh`): SSH config parsing, auth prompts, command execution, and the - tunnel/environment manager behind desktop-managed SSH environments. -- `packages/tailscale` (`@t3tools/tailscale`): Tailscale CLI wrapper, including the - `ensureTailscaleServe` / `disableTailscaleServe` serve lifecycle the server drives. -- `packages/effect-acp` (`effect-acp`): Effect client and agent implementation of the Agent Client - Protocol, used by ACP-speaking provider drivers. -- `packages/effect-codex-app-server` (`effect-codex-app-server`): Effect client for the - `codex app-server` JSON-RPC protocol. - -## infra - -- `infra/relay` (`t3code-relay`): the hosted T3 Connect relay, deployed with Alchemy. Handles - environment discovery, cloud-side records, and mobile notifications. It is not in the hot path; - after connect, client traffic goes directly to the environment. See - [t3-connect.md](./t3-connect.md). - -## Other top-level directories - -- `scripts/`: workspace tooling run through `vp run`. Dev runner, desktop artifact builds, release - helpers, mobile static checks and showcase capture, update-manifest merging. -- `assets/`: brand and app icon sources per channel (`dev`, `nightly`, `prod`). -- `patches/`: pnpm patches for pinned upstream dependencies. -- `oxlint-plugin-t3code/`: repo-specific lint rules. -- `experiments/`: throwaway prototypes. Not part of the shipped build. -- `docs/`: this documentation tree. - -## Import conventions - -`@t3tools/shared` and `@t3tools/client-runtime` use explicit subpath exports with no barrel index and -no root export. Import the narrow path (`@t3tools/shared/DrainableWorker`, -`@t3tools/client-runtime/state/threads`) rather than the package root. Files that are not exported -are implementation details. `@t3tools/contracts` does export a root alongside `./settings` and -`./relay`. diff --git a/docs/operations/connect-setup.md b/docs/operations/connect-setup.md new file mode 100644 index 000000000000..49b93e49c9c4 --- /dev/null +++ b/docs/operations/connect-setup.md @@ -0,0 +1,126 @@ +# T3 Connect setup + +Deployment and client configuration for T3 Connect. The [architecture note](../internals/t3-connect.md) +explains the trust boundaries; the [relay README](../../infra/relay/README.md#deployment) owns relay +provisioning instructions. + +## Public application configuration + +T3 Connect is disabled in a fresh clone. To build against the production deployment, copy the +repository-root example: + +```sh +cp .env.example .env +``` + +For another deployment, set these values in the repository-root `.env` or `.env.local`: + +```dotenv +T3CODE_CLERK_PUBLISHABLE_KEY= +T3CODE_CLERK_JWT_TEMPLATE= +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID= +T3CODE_RELAY_URL=https://relay.example.com +``` + +Process variables take precedence over `.env.local`, then `.env`. Use these canonical names; +the build loader supplies framework-specific aliases. These values are public identifiers. +`CLERK_SECRET_KEY` belongs only in the relay's secrets, never in client configuration. + +Client and bundled-server builds embed the public values, so set them before building. +EAS preview and production environments need the publishable key, JWT template name, and relay URL. +Bundled servers also accept runtime overrides for operator-managed deployments. + +Copy `infra/relay/.env.example` to `infra/relay/.env` for relay deployment settings. +Deploy `prod` before personal stages because it owns the retained database that their branches +depend on. The deploy wrapper writes the resulting relay URL back to the root `.env`. + +## CLI OAuth application + +In Clerk's OAuth applications settings: + +1. Create a public OAuth application for the T3 CLI, using authorization-code exchange with PKCE. +2. Allow both redirect URIs: `http://127.0.0.1:34338/callback` and + `https://app.t3.codes/connect/callback`. A custom `T3CODE_HOSTED_APP_URL` needs its own + `/connect/callback` URL. Headless and SSH authorization depend on the hosted redirect. +3. Enable the `openid`, `profile`, and `email` scopes. +4. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release + build environments. + +## JWT template + +Create a Clerk JWT template named `t3-relay` with claims: + +```json +{ "aud": "t3-code-relay" } +``` + +Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` for clients and +`CLERK_JWT_AUDIENCE=t3-code-relay` for the relay. The production relay deployment environment +also defines `CLERK_JWT_TEMPLATE`. The audience stays the same across relay stages; the relay +URL selects the deployment. + +## Desktop OAuth redirects + +Enable Clerk's Native API and add the desktop redirects to its SSO redirect allowlist: + +```text +t3code-dev://app/ +t3code://app/ +``` + +Add the corresponding origin to the Clerk instance's Backend API `allowed_origins` array. +Development uses `t3code-dev://app`; production uses `t3code://app`. Update the array with +`PATCH https://api.clerk.com/v1/instance` using the Clerk secret key, preserving existing entries. +The Clerk Electron integration handles token +persistence and system-browser callback delivery. + +## Desktop passkeys + +For a production macOS app with bundle ID `com.t3tools.t3code`: + +1. Create an explicit macOS App ID in the Apple Developer portal with **Associated Domains**. +2. Create a provisioning profile for that App ID and the distribution signing certificate. +3. In Clerk's Native API settings, add an iOS app with the same Apple Team ID and bundle ID. + This setting also configures Electron/macOS passkeys. +4. Check `https:///.well-known/apple-app-site-association`. Its + `webcredentials.apps` must include `.com.t3tools.t3code`. +5. Configure signing as described in the [release runbook](./release.md#2-apple-signing--notarization-setup-macos). + +Local signed builds additionally use: + +```dotenv +T3CODE_APPLE_TEAM_ID=ABC1234567 +T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile +# Override only when the RP domain differs from the Clerk Frontend API hostname. +T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com +``` + +Without the override, the build derives the RP domain from the Clerk publishable key. +After changing Associated Domains, bump the build version before rebuilding. macOS can otherwise +reuse stale Shared Web Credentials metadata for the same app/version pair. + +The ordinary `dev:desktop` launcher is unsigned and cannot exercise macOS passkeys. For renderer +HMR, install a signed build, start `vp run dev:web`, and launch the installed executable with the +actual web and server ports. For example, with the default ports: + +```sh +VITE_DEV_SERVER_URL=http://127.0.0.1:5733 \ +T3CODE_PORT=13773 \ + "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)" +``` + +Rebuild the signed app after native dependency, main-process, preload, entitlement, provisioning, +or signing changes. Renderer edits can reuse it. Verify the installed bundle before testing: + +```sh +codesign --verify --deep --strict "/Applications/T3 Code (Alpha).app" +codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app" +``` + +## Restricting sign-ups + +Use Clerk's allowlist for permitted email addresses or domains, or Restricted mode for invitation-only +sign-up. An enabled empty allowlist blocks all new sign-ups. + +Sign-up restrictions do not revoke an existing account's access. Ban the account in Clerk when +its active sessions and future sign-ins must be disabled. diff --git a/docs/operations/development.md b/docs/operations/development.md new file mode 100644 index 000000000000..76edcb1e82f1 --- /dev/null +++ b/docs/operations/development.md @@ -0,0 +1,141 @@ +# Development + +## First checkout + +Install `vp` using the [root README](../../README.md#install-vp). The checkout requires Node 24; +Bun is optional. From the repository root: + +```sh +vp i +vp run dev +``` + +Open the one-time pairing URL printed by the dev runner. The bare origin does not authenticate +a new browser. + +## Choosing a dev process + +Use `vp run dev` for server and web, or `vp run dev:desktop` for the Electron client. +`dev:server` and `dev:web` start those processes separately. +See the [mobile README](../../apps/mobile/README.md) for native builds and Metro. + +Flags go directly after the task name, for example `vp run dev --home-dir /tmp/t3code-dev`. +Add `--browser` to open a browser automatically. + +### State and ports + +Linked worktrees default to their own `.t3/userdata`, even when `T3CODE_HOME` is set. +The main checkout defaults to `~/.t3/dev/userdata`. An explicit `--home-dir` wins in both cases. +Never run a development server against the live `~/.t3/userdata`. +See the [isolated fixture guide](../../.agents/skills/test-t3-app/references/sqlite-fixtures.md) for preparing test data. + +Read ports from the `[dev-runner]` output. Worktrees derive stable preferences from their paths, +but occupied ports can shift them. `T3CODE_PORT_OFFSET` or `T3CODE_DEV_INSTANCE` can select a +different preference when needed. + +### Sharing and remote debugging + +`vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL +for that origin. Give the tester the complete URL, including its token. The dev runner removes +its mapping on exit. + +Leave `VITE_HTTP_URL` and `VITE_WS_URL` unset. Vite proxies the backend through the browser's +origin so the same build works over localhost and remote connections. + +Shared runs use standard dev serving by default. Set `T3CODE_BUNDLED_DEV=1` to opt into +bundled dev when network round trips dominate startup. Two reload traps matter +when changing this setup: + +- The web entry must dynamically import the app so React refresh initializes before application + chunks. Static imports can work on first load and fail after a route split. +- Bundled dev rebuilds Tailwind through watched files. Its ordinary Vite hot-update hook expects + a server/module graph that Rolldown does not provide. + +The workarounds live in the [web entry](../../apps/web/src/bootstrap.ts) and +[Tailwind plugin](../../apps/web/vite/tailwind.ts). + +## Checks + +Run checks for the files and packages you changed: + +```sh +vp test run +vp lint +vp run --filter typecheck +``` + +Run the workspace `vp check` and `vp run typecheck` before completing a change. +Use `vp run lint:mobile` for native mobile changes. See +[ci.yml](../../.github/workflows/ci.yml) for its current jobs. +The [manual Windows lane](../../.github/workflows/windows-tests.yml) is available for focused +Windows investigation while that suite is not a required gate. + +## Desktop artifacts + +Local artifact builds are unsigned by default and write to `release/`: + +```sh +vp run dist:desktop:dmg +vp run dist:desktop:linux +vp run dist:desktop:win +``` + +DMGs default to the host architecture. Use `--arch` to choose another target and `--keep-stage` +to retain packaging files for inspection. Run `vp run dist:desktop:artifact --help` for other +options. + +### Linux AppImage prerequisites + +Build on Linux because the browser-secret helper links against the host's libsecret. Install +Rust, C/C++ build tools, libsecret development headers, pkg-config, and ImageMagick. + +Ubuntu and Debian: + +```sh +sudo apt-get update +sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick +``` + +Fedora: + +```sh +sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick +``` + +Arch Linux: + +```sh +sudo pacman -S rust base-devel libsecret pkgconf imagemagick +``` + +The C toolchain, pkg-config, and libsecret headers are also needed for Linux desktop development. + +### macOS DMG prerequisites + +Install the Xcode Command Line Tools with `xcode-select --install` and install Rust. +For a cross-architecture or universal build, add the requested Rust targets: + +```sh +rustup target add aarch64-apple-darwin x86_64-apple-darwin +``` + +### Windows installer prerequisites + +Install Rust, Python 3, and Visual Studio Build Tools with **Desktop development with C++**. +Include the Windows SDK and the MSVC build tools and Spectre-mitigated libraries for the target +architecture. Add its Rust target: + +```powershell +rustup target add x86_64-pc-windows-msvc +# For an ARM64 installer: +rustup target add aarch64-pc-windows-msvc +``` + +NSIS is downloaded by electron-builder. WSL support additionally needs a Linux node-pty prebuild; +see the [release runbook](./release.md#windows-payload-topology-and-update-validation). + +### Signing and passkeys + +Add `--signed` after configuring the platform credentials in the +[release runbook](./release.md). macOS passkeys need a signed, provisioned app; follow the +[Connect setup](./connect-setup.md#desktop-passkeys) for local signing and renderer HMR. diff --git a/docs/operations/release.md b/docs/operations/release.md index 181e651e59d1..44f5404da075 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -321,7 +321,7 @@ Checklist: - `APPLE_API_KEY`: contents of the downloaded `.p8` - `APPLE_API_KEY_ID`: Key ID - `APPLE_API_ISSUER`: Issuer ID -10. Complete the Clerk Native API and AASA setup in [T3 Connect Clerk Setup](../internals/t3-connect.md#desktop-passkeys). +10. Complete the Clerk Native API and AASA setup in [T3 Connect setup](./connect-setup.md#desktop-passkeys). 11. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected `com.apple.developer.associated-domains` entitlement. diff --git a/docs/user/appearance.md b/docs/user/appearance.md new file mode 100644 index 000000000000..0300b8fd9946 --- /dev/null +++ b/docs/user/appearance.md @@ -0,0 +1,73 @@ +# Appearance and themes + +Open **Settings → Appearance** to choose a theme and follow the system appearance or stay in light +or dark mode. To use different themes for light and dark mode, select the corresponding preview +within each theme. Appearance preferences are saved separately on each device or browser. + +Mobile has its own themes and text, code, and terminal preferences. It does not follow environment +themes or defaults. + +## Motion + +The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the +**Panel animations** slider above 0 ms to add motion, up to 400 ms, unless reduced motion is enabled +in your operating system. Moving between threads always snaps to the selected thread's panel state +without replaying its transitions. + +## Custom themes + +On web and desktop, choose **Create theme** to adjust a palette, or import a T3 Code or VS Code +theme. The theme editor's color picker lets you select an area of the app to find the color to +change. Export your theme as JSON to share it. + +## Environment themes + +Environment themes and defaults come from the server serving your web app or the desktop app's +main local environment. app.t3.codes and additional connections do not use them. + +Select a published theme in **Settings → Appearance** to follow its palette as the server updates +it. **Duplicate** makes an independent copy you can edit. A saved custom theme with the same ID +takes precedence. If the server stops publishing the selected theme, T3 Code falls back to its +standard theme. + +Run this on the server to set a default and switch connected clients to it: + +```bash +t3 theme set nightfall +``` + +Clients that are offline apply it when they reconnect. Each client applies the setting once; +choosing another theme afterward sticks until the next `t3 theme set`. Run the command again to +reapply it, even if the name is unchanged. + +`t3 theme clear` removes the default without changing anyone's current theme. `t3 theme show` lists +the default and published themes. + +### Publish a theme + +Save a theme exported from T3 Code into `~/.t3/userdata/themes/` on the server, or the `themes` +directory under your custom state directory. The filename supplies the theme ID: `nightfall.json` +can be selected with `t3 theme set nightfall`. Keep the filename stable when updating its colors. +Do not use `system`, `light`, `dark`, or a built-in theme's ID. + +For an integration that generates a palette, this shorter format also works: + +```json +{ + "name": "Nightfall", + "appearance": "dark", + "canvas": "#1a1b26", + "accent": "#7aa2f7", + "colors": { + "terminalSelection": "#292e42", + "error": "#f7768e" + } +} +``` + +Set `appearance` to `light` or `dark` and supply hex colors for `canvas` and `accent`. T3 Code +generates the rest. The optional `colors` overrides use the names in the theme editor's advanced +view. + +Write updates to a temporary file and rename it into place so clients never read a partial theme. +Invalid files are not published. diff --git a/docs/user/background-service.md b/docs/user/background-service.md index a89fc2f7842d..eecd6fa77b3a 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -1,123 +1,81 @@ -# Running T3 Code in the Background +# Running T3 Code in the background -On Linux and macOS, T3 Code can run as a background service for your user, so it is ready without -keeping a terminal open. +On Linux and macOS, T3 Code can run as a service for your user so you do not need +to keep a terminal open. -## Manage the Service +## Manage the service -Install it with the latest T3 Code release: +Run these commands on the machine that will host T3 Code: -```sh -npx t3@latest service install -``` - -Check whether it is installed. On Linux this also checks whether the service is running, enabled -at startup, and allowed to keep running after logout: - -```sh -npx t3@latest service status -``` - -Update or repair it: - -```sh -npx t3@latest service update -``` - -The service uses the same T3 Code version as the CLI you run. To install a nightly or an exact -version, use that version of the CLI: - -```sh -npx t3@nightly service update -npx t3@1.2.3 service update -``` - -The install and update commands refuse to replace a newer service with an older version. Setup -through T3 Connect leaves a newer service unchanged. To downgrade, select the exact older version -and pass `--allow-downgrade`: +| Task | Command | +| ------------------------------- | --------------------------------- | +| Install and start | `npx t3@latest service install` | +| Inspect status and log location | `npx t3@latest service status` | +| Update or repair | `npx t3@latest service update` | +| Stop and remove from startup | `npx t3@latest service uninstall` | -```sh -npx t3@1.2.3 service update --allow-downgrade -``` - -Stop it and remove it from startup: - -```sh -npx t3@latest service uninstall -``` - -Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. -If a remote update is already in progress, wait for it to finish before retrying a local update. - -The service runs a small stable launcher. Exact T3 Code versions are installed separately, so a -failed remote candidate can return to the previous version without rewriting the service -definition. The launcher snapshots the database before a remote candidate starts, so database -updates roll back with the server version. An older launcher may require one local -`service update` before this is available. +Uninstalling the service leaves your projects, threads, and settings intact. -## Platform Support +Install and update use the version of the CLI you invoke. For nightly, use +`npx t3@nightly service update`; replace `nightly` with an exact version to pin +one. An older CLI refuses to replace a newer service unless you explicitly add +`--allow-downgrade`. -**Linux** uses a systemd user unit at `~/.config/systemd/user/t3code.service`. The service starts -when the machine boots and keeps running after you log out (lingering is enabled during install). -Setup checks the systemd user manager and enables lingering before installing a runtime or stopping -an existing service. If that requires administrator permission, setup stops with a recovery command. +Updating restarts the server. Finish active work first, and wait for any remote +update already in progress. To match a remote client's version, follow +[Updating T3 Code](./updating.md). -**macOS** uses a launch agent at `~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. It -starts when you log in, not when the Mac boots, and it stops when you log out; macOS has no -equivalent of Linux lingering for user agents. For a Mac that should stay reachable unattended, -turn on automatic login (System Settings → Users & Groups; unavailable while FileVault is on) and -keep the Mac from sleeping. +## Platform support -A few more macOS notes: +Linux needs systemd user services. Setup enables lingering so T3 Code starts at +boot and keeps running after logout. If this needs administrator permission, +setup prints a recovery command before changing the service. -- Installing over SSH needs someone logged in at the Mac's screen to start the agent right away. - Without that, the install command reports an error at the final start step, but the agent is - fully installed and starts at the next login. -- macOS may show privacy prompts for protected folders such as Desktop, Documents, or Downloads, - attributed to a bare `node` process, or deny access without a prompt. If agent work fails to - read those folders, grant Full Disk Access to the node binary listed in the launch agent's - `ProgramArguments`. -- The agent appears under System Settings → General → Login Items. If it was switched off there, - or disabled with `launchctl disable`, macOS will not start it at login until you switch it back - on. +macOS starts the service when you log in and stops it when you log out. Keep the +Mac logged in and awake for unattended remote access. Installing over SSH while +nobody is logged in at the Mac's screen can fail at the final start step; the +service is still installed and will start at the next login. -**Windows** is not supported yet. +Windows background services are not supported. -## Using It with T3 Connect - -T3 Connect may offer to install the service during setup so the host stays reachable in the -background. This is only an onboarding shortcut: the service and T3 Connect are managed separately. - -Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer -want T3 Code to start in the background. +T3 Connect can offer service installation during setup, but the two are managed +separately. Signing out of T3 Connect does not stop or uninstall the service. ## Troubleshooting -Run `t3 service status` on the server machine. An installed version alone does not mean the service -is running or will survive logout. Linux status reports these problems: +Start with `t3 service status` on the host. It prints the log path and, on Linux, +checks whether the installed service is running, enabled, and allowed to survive +logout. -| Code | What it means | Recovery | -| -------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `linger-disabled` | The service stops after your last login session ends and does not start at boot. | Run `sudo loginctl enable-linger "$(id -un)"`, then retry setup as your normal user. | -| `linger-unavailable` | T3 Code could not verify the logout setting. | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. | -| `user-manager-unavailable` | T3 Code cannot reach your systemd user manager. | Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if needed. | -| `service-disabled` | The service is not enabled to start automatically. | Run the repair command shown by `t3 service status`. | -| `service-stopped` | The service is installed but is not running. | Read the service log and `systemctl --user status t3code.service`, then run the displayed repair command. | - -For an SSH host, run the administrator command in an interactive terminal so sudo can prompt for -your password: +If it stops when your SSH session closes, check for `linger-disabled`. An +administrator can enable lingering with: ```sh -ssh -t your-server 'sudo loginctl enable-linger "$(id -un)"' +sudo loginctl enable-linger "$(id -un)" ``` -Run only the `loginctl` command with sudo. Running `t3` with sudo creates a separate installation and -Connect identity for root. If an administrator is unavailable, run `t3 serve` in a terminal and -keep that session open. +Over SSH, allow sudo to prompt: -The repair command shown by status uses the CLI version, or the installed service version if that -is newer. An older stable CLI therefore does not recommend downgrading a nightly installation. -Setup leaves an existing service running if the user-manager or lingering check fails. +```sh +ssh -t your-server 'sudo loginctl enable-linger "$(id -un)"' +``` -`t3 service status` prints the log path. The adjacent `server.trace.ndjson` file contains detailed -server traces. For failures after authorization, see [T3 Connect troubleshooting](./remote-access.md#t3-connect-troubleshooting). +Then retry service setup as your normal user. Run only the `loginctl` command +with sudo; running T3 Code as root creates a separate installation and Connect +identity. Without administrator access, run `t3 serve` in a terminal and keep +that session open. + +| Status problem | Next step | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `linger-unavailable` | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. | +| `user-manager-unavailable` | Run `systemctl --user status` in a login session for the service user; check your distribution's systemd user-session support. | +| `service-disabled` or `service-stopped` | Read the log and `systemctl --user status t3code.service`, then use the repair command printed by T3 Code. | + +On macOS, check **System Settings → General → Login Items** if the service no +longer starts at login. If agent work cannot access Desktop, Documents, or +Downloads, it may need Full Disk Access for the Node executable listed in +`ProgramArguments` in +`~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. + +For failures after signing in to T3 Connect, see +[connection troubleshooting](./remote-access.md#t3-connect-troubleshooting). diff --git a/docs/user/browser-import.md b/docs/user/browser-import.md index 4c911f6262a3..93f2fad73741 100644 --- a/docs/user/browser-import.md +++ b/docs/user/browser-import.md @@ -1,17 +1,21 @@ -# Import browser logins +# Import browser sessions -In the desktop app, open **Settings → Integrations → Browser profiles → Add profile** -and choose a browser under **Import from**. The import copies cookies into a T3 Code browser -profile so you can use existing logins in the preview browser. Changes made afterward stay -separate from the source browser. +The desktop app can import cookies from another browser so you can reuse its signed-in sessions +in the preview browser. -Linux discovery includes Helium and both native and Snap installations of Firefox. Windows -discovery includes Firefox and Helium builds that still use Windows' standard profile -encryption. Other Chromium-based browsers on Windows use app-bound cookie encryption and cannot -be imported. A browser appears once it has a profile with a cookie database. Close the source -browser before importing; the import wizard will prompt you if it is still running. +Open **Settings → Integrations → Browser profiles → Add profile**, then choose a browser under +**Import from**. Close the source browser before importing, and allow an operating-system keyring +unlock prompt if one appears. -On Linux, Chromium-based browsers use your desktop keyring to protect their cookies. T3 Code -includes the keyring reader; no separate command-line tool is needed. Allow the desktop unlock -prompt if one appears. If the keyring cannot be accessed, T3 Code reports that failure when no -cookies can be imported. Partitioned cookies are skipped. +This is a one-time copy. Later login changes stay separate between the two browsers, and some +sites may still require you to sign in again. + +On macOS, Safari is also available. Safari protects its cookies with Full Disk Access rather than +a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the +right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can +revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies +kept by additional Safari profiles are not. + +On Windows, import supports Firefox and Helium profiles that use standard profile encryption. +Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies +are skipped on all platforms. diff --git a/docs/user/composer.md b/docs/user/composer.md index f158eb8f185e..4a8df5333664 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -1,240 +1,138 @@ -# Message composer - -Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the -composer and shows how many characters need to be removed. Shorten the draft or split it into -multiple messages, then send again in the same thread. - -On mobile, an empty composer shows an interrupt button while the agent is working. Adding text -or an attachment replaces it with the send button. This applies to both compact and expanded -composers. - -You can attach images up to 10 MB. On servers that support file uploads, you can also -attach videos, text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised -by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files -upload directly to the environment, where your agent can read, copy, or edit them by their file path. - -Attachments upload as soon as you add them while connected to a server that supports uploads. -The send button becomes available after every upload finishes. Failed uploads can be retried or -removed. On mobile, tap **+** to open -the photo library from either the compact or expanded composer. When the connected server supports -file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**. -Videos use the server's file upload limit. You can also share photos, videos, and files into -T3 Code from other apps through the system share sheet. Mobile keeps a local copy of each draft -attachment, so you can still preview it and queue messages while offline. Uploads resume when -you reconnect. Drafts and queued messages survive app restarts; signing out of T3 Connect keeps -them on your device until you sign back into the same account. Select a received file on mobile -to preview it or open the system share options. - -Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail -into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return. -Use Share to save a copy or send it to another app. PDFs support page navigation and search. -PDF links in assistant responses open the same preview. On Android, images open in the image -viewer and PDFs open the system chooser. - -On web and desktop, select a sent PDF or HTML attachment to open it in the file viewer, or use the -download button beside it to save a copy. Other attached files download when selected. - -Select a video attachment before or after sending to play it. Web and desktop use the browser's -built-in controls. On mobile, videos open in a full-screen player with native playback controls. -Supported videos show a thumbnail in the conversation and composer. -Received videos stream from their environment as they play on every platform. Supported formats and -codecs depend on the browser or device; you can save an unsupported video to open it in another app. - -On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the -conversation or draft. Touch and hold a video thumbnail, then choose **Save or share** to open -the system share options. On Android, the same menu is also available inside the preview. - -On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name -and shows **Attach again** next to it. Attach the file again or remove it, then send. - -On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into -the composer or paste them into a message. On iOS, selecting them from **Photo Library** also -converts them to JPEG. The 10 MB image limit applies to the converted photo. - -On web and desktop, an existing thread settles its composer into a single-line resting state when -the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer, -except when scrolling toward the end while already there. When the thread-context strip has room, -the model and mode controls stay available beside the thread context; otherwise they return when the -composer is focused. Focus the composer or start typing to expand it again. The conversation keeps -the expanded composer's space clear above its last message while the composer rests, so expanding it -again never covers what you scrolled to. New-thread layouts keep the full composer. **Settings → General → Collapse composer** chooses which triggers rest it: -**On unfocus**, **On scroll**, both, or neither. With neither selected the composer stays expanded. - -At phone-sized web or desktop window widths, existing threads animate between their compact and -expanded layouts. Up to three image attachments remain visible in either resting layout, followed -by a count when more are attached. At wider sizes, videos, files, and other draft context remain -visible at their natural height; the phone-sized compact row reveals those details when expanded. - -On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, -GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list -when starting a thread or changing an existing thread's model. +# Messages and context -## Model defaults +Give the agent a task in the composer. Add files, quote a previous response, or +include a skill when the task needs more context. -T3 Code remembers the last provider, model, and model options you selected and reuses that -selection for new threads. A model configured in a project's settings overrides the remembered -selection for that project; resetting the project setting returns it to the remembered selection. +Messages can contain up to 120,000 characters. Longer drafts stay in the composer +so you can shorten them or split them into several messages. -Model options shown as provider defaults remain display values until you choose them in T3 Code. -T3 Code only sends options you selected explicitly, so an unset reasoning level or service tier can -still come from the provider's own configuration. +## Attach files -## Quote an assistant response +Attach up to eight files per message. Images can be up to 10 MB; other files can +be up to 50 MB, subject to the environment's upload support and limit. The agent +receives them on the environment's machine. -On web and desktop, select text in an assistant response, then choose **Cite in composer** from the -menu that appears when you release the selection. This inserts an inline quote chip at your cursor -and opens an optional comment bubble beside the selected text; press `Enter` or choose **Save** to -attach the comment, or leave it blank to keep just the quote. You can type before and after the -chip, such as a quote followed by "what do you mean?". A selection must stay within one response -and fit in 8,000 characters. +Uploads begin when you add an attachment. All uploads must finish before the +message can send. Retry or remove a failed upload. On web and desktop, reloading +before an upload finishes requires you to attach that file again. -The chip shows your comment when it has one, or a short quote preview otherwise. Use the pencil -button to add or change the comment, and the remove button to delete the quote and its comment from -the draft. Copying, reloading, and restoring a [stashed prompt](#prompt-stash) keep each comment -with its quote, and sending tells the agent which words were quoted and which comment you wrote. -The quoted text and comment count toward the message limit. +You can drag or paste images into the web or desktop composer. HEIC and HEIF +photos are converted to JPEG there and when selected from the iOS photo library; +the image limit applies after conversion. On mobile, you can also send files to +T3 Code through another app's system share sheet. -Select a chip in the composer or a sent message to open the source thread, scroll to the response, -and highlight the quoted passage — including in older history. The -highlight pulses, holds for a moment, then fades on its own; press `Escape` to stop the navigation -or clear it early. If the source is unavailable or its text has changed, the saved quote stays -readable and T3 Code shows a warning. +See [images and videos](#images-and-videos-in-messages) for previewing and saving media. -Mobile shows the full saved quote and its comment in sent messages. It does not offer -**Cite in composer** or navigation to a quote's source. +## Queue messages offline on mobile -## Images and videos in messages +Mobile keeps local copies of draft attachments, so you can preview them and queue +messages while disconnected. Uploads resume when you reconnect. Drafts and queued +messages survive app restarts. Signing out of T3 Connect keeps that work on your +device until you sign back into the same account. -On web, desktop, and mobile, select a link to an image or video to open it inside T3 Code. -Workspace image and video links open the file viewer. Links to media outside the workspace -open a media preview. -Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They -stream from the environment as needed, rather than downloading the entire video before playback. -Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline; -video embeds show a player with the browser's controls, full screen included. Visible video previews load -an initial frame when supported, but stay paused until you press Play. Video file references use -a filmstrip icon. - -On web and desktop, hover over a preview to see its full file path or original URL. Right-click -to copy that reference, save the image or video, or copy an image to the clipboard. The video -player's built-in controls can download a video too. If the player cannot decode a video, its error message -offers a link to open the source in the browser. Workspace media also offers **Copy relative -path** and **Open in file viewer**. These actions are available in expanded previews too. - -On mobile, touch and hold an inline image or a video thumbnail to see its source, -copy the path or URL, or choose **Save or share**. Workspace files can open in the file viewer -from the same menu. Saving downloads a copy only when you request it; it does not change how -the video buffers during playback. On iOS, touch and hold a file reference in a message to -copy its full or relative path or open it in the file viewer. - -Use Markdown image syntax to embed either kind of media: - -```markdown -![Screenshot](/tmp/screenshot.png) -![Recording](/tmp/recording.mp4) -[Open recording](/tmp/recording.mp4) -``` - -Relative paths resolve from the thread's workspace. Absolute paths and `file://` links refer to -the environment's machine, even when you connect remotely or use your phone. Supported media -can live outside the workspace, including in Downloads or `/tmp`. - -T3 Code serves the original file without adding it to attachment storage. If that file is moved -or deleted, its preview can no longer load from the environment. A browser or device may still -have a cached copy. Supported video formats and codecs depend on the browser or device. - -Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `