diff --git a/FORK.md b/FORK.md index 8cd04efc5a47d..f99e88c50f239 100644 --- a/FORK.md +++ b/FORK.md @@ -126,8 +126,8 @@ The shared plumbing packets register into, specified in | ----------------------------------------------------------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- | | `apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx` | `ext-settings` | Mount project agent-tool choices for each selected computer. | | `apps/server/src/provider/AgentContextBudget.test.ts` | `ext-mcp` | Measure the ordinary session catalog through production capability filtering. | -| `apps/server/src/orchestration-v2/ProviderSessionManager.ts` | `ext-mcp` | Prepare and reuse credentials with project tool-family capabilities. | -| `apps/server/src/orchestration-v2/ProviderSessionManager.test.ts` | `ext-mcp` | Verify stable credential reuse and replacement when tool families change. | +| `apps/server/src/orchestration-v2/ProviderSessionManager.ts` | `ext-mcp` | Prepare project tool-family capabilities and keep credentials when they change. | +| `apps/server/src/orchestration-v2/ProviderSessionManager.test.ts` | `ext-mcp` | Verify the credential stays stable when tool families change. | | `apps/server/src/mcp/McpInvocationContext.ts` | `ext-mcp` | Admit Hardware, Reach and Sites session capabilities. | | `apps/server/src/mcp/McpToolAccess.ts` | `ext-mcp` | Check family capabilities on direct tool calls. | | `apps/server/src/server.ts` | `ext-mcp` | Supply the shared tool policy before orchestration without a fork-runtime cycle. | @@ -249,6 +249,8 @@ The renderer follows the web app's React version, which comes from the workspace | File | Packet | Why | | --------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------- | | `apps/web/src/hooks/useThreadActionMenu.ts` | `attention-status` | Retry failed runs and resume held queues from the conversation header. | +| `apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts` | `project-profiles` | Pre-approve the read-only project settings tool in read-only sandboxes. | +| `evals/mcp-tool-use/devices.test.ts` | `project-profiles` | Expect the project-scoped device access denial agents can resolve from chat. | | `apps/web/src/components/Sidebar.tsx` | `library-projects` | Use the Library retention setting in single and bulk delete confirmations. | | `apps/web/src/hooks/useThreadActions.permissions.test.ts` | `library-projects` | Await async confirmation while preserving permission rechecks. | | `apps/web/src/hooks/useThreadActions.ts` | `library-projects` | Explain saved Library retention before deleting an archived thread. | @@ -280,7 +282,6 @@ The renderer follows the web app's React version, which comes from the workspace | `apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx` | `cloudflare-access` | Keep the native Access keychain out of markdown image tests. | | `apps/server/src/orchestration-v2/ThreadFork.execution.test.ts` | `deleted-history` | Expect the deleted-source refusal that rejects a side chat first. | | `apps/server/src/process/externalLauncher.test.ts` | `hermetic-tests` | Keep the host's installed editors out of the Insiders bundle case. | -| `apps/server/src/usage/UsageService.test.ts` | `hermetic-tests` | Pin the clock so removed-directory usage stays inside retention. | L46 adds the print check worker to the server pack entries in `apps/server/vite.config.ts` (`fork: model-print-check`) so the worker ships beside `bin.mjs`. The single-file executable @@ -593,11 +594,9 @@ The `test-temp-isolation` seam in `apps/server/src/git/GitManager.test.ts` (9) c test's extra worktree inside its own scoped temp directory. Upstream placed them beside the scoped repository, so every run left orphaned worktree folders in `$TMPDIR`. -The `hermetic-tests` seams keep upstream tests independent of the host and today's date. +The `hermetic-tests` seams keep upstream tests independent of the host. `externalLauncher.test.ts` (3 marker lines) hides the host's real editor installs from the -macOS VS Code Insiders case, and `UsageService.test.ts` (2) pins the clock in the -removed-directory usage case so its saved usage is not pruned. Upstream's `main` pins that -clock itself after this nightly; drop the `UsageService.test.ts` seam once that lands. +macOS VS Code Insiders case. The `agent-work` seams add one Delete all for archived subagent and bot run conversations to Settings > Archived. Parent conversations keep their agent receipts and runs keep their results, diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs index 678b62f58aaca..6e1f767d7d395 100644 --- a/apps/desktop/scripts/main-process-bundle.test.mjs +++ b/apps/desktop/scripts/main-process-bundle.test.mjs @@ -3,6 +3,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeVM from "node:vm"; +import * as NodeURL from "node:url"; import { build } from "vite-plus/pack"; import { assert, it } from "vite-plus/test"; @@ -114,18 +115,16 @@ it("loads the emitted packaged boot entry and backend cache preload", async () = const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-")); try { const entries = ["src/boot.ts", "src/compileCache.ts"]; - await NodeFSP.mkdir(NodePath.join(directory, "src")); - await Promise.all( - entries.map((entry) => - NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)), - ), - ); assert.ok(Array.isArray(desktopConfig.pack)); for (const packConfig of desktopConfig.pack) { if (!Array.isArray(packConfig.entry)) continue; if (!packConfig.entry.some((entry) => entries.includes(entry))) continue; await build({ ...packConfig, + entry: packConfig.entry.map((entry) => + NodeURL.fileURLToPath(new URL(`../${entry}`, import.meta.url)), + ), + outDir: NodePath.join(directory, "dist-electron"), config: false, cwd: directory, tsconfig: false, diff --git a/apps/desktop/src/app/DesktopManagedPolicy.ts b/apps/desktop/src/app/DesktopManagedPolicy.ts new file mode 100644 index 0000000000000..6dc9e8c21b2ae --- /dev/null +++ b/apps/desktop/src/app/DesktopManagedPolicy.ts @@ -0,0 +1,65 @@ +/** + * DesktopManagedPolicy - the administrator's managed policy as the desktop app reads it, at most + * once: every desktop check needs the same answer. + * + * @module DesktopManagedPolicy + */ +import type { ManagedPolicyDocument } from "@t3tools/contracts"; +import { + layerWindowsRegistry, + ManagedPolicyFileError, + readManagedPolicy, + WindowsRegistry, +} from "@t3tools/shared/managedPolicyFile"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/process"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +type FoundPolicy = Effect.Success>; +const isManagedPolicyFileError = Schema.is(ManagedPolicyFileError); + +export class DesktopManagedPolicy extends Context.Service< + DesktopManagedPolicy, + { + /** The policy on this machine, undefined without one; fails when it cannot be used. */ + readonly read: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopManagedPolicy") {} + +export const layer = Layer.effect( + DesktopManagedPolicy, + Effect.gen(function* () { + const { platform } = yield* DesktopEnvironment.DesktopEnvironment; + const read = yield* Effect.cached( + readManagedPolicy(platform).pipe( + Effect.provideService(FileSystem.FileSystem, yield* FileSystem.FileSystem), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + yield* ChildProcessSpawner.ChildProcessSpawner, + ), + Effect.provideService(WindowsRegistry, yield* WindowsRegistry), + ), + ); + return DesktopManagedPolicy.of({ read }); + }), +).pipe(Layer.provide(layerWindowsRegistry(() => import("ffi-rs")))); + +/** A machine with `policy` deployed, one whose policy cannot be used, or none. */ +export const layerTest = (policy?: ManagedPolicyDocument | ManagedPolicyFileError) => + Layer.succeed( + DesktopManagedPolicy, + DesktopManagedPolicy.of({ + read: + policy === undefined + ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - No policy is undefined, not void. + Effect.succeed(undefined) + : isManagedPolicyFileError(policy) + ? Effect.fail(policy) + : Effect.succeed({ source: "/test/policy.json", document: policy }), + }), + ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 9b93e37a0ba6b..3803279360847 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -8,9 +8,11 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -22,6 +24,8 @@ import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; const layerShellEnvironment = (prepared = true) => Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(prepared), + awaitDiscovery: Effect.succeed(prepared), + updates: Stream.empty, }); const layerBackendConfiguration = (prepared = true) => @@ -245,6 +249,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(first.cwd, environment.backendCwd); assert.equal(first.captureOutput, true); assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); + assert.equal(first.env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING, "1"); assert.isUndefined(first.env.T3CODE_PORT); assert.isUndefined(first.env.T3CODE_MODE); assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); @@ -1010,6 +1015,7 @@ describe("DesktopBackendConfiguration", () => { Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layer), + Layer.provideMerge(DesktopManagedPolicy.layerTest()), Layer.provideMerge( Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index ae958576f7586..0d5dfb359ba9e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -543,6 +543,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", + T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1", T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, extendEnv: true, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 5aea300b35a07..2159eff60216d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; // fork: tailnet-ownership @@ -14,6 +15,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -133,6 +135,7 @@ function layer(input: { readonly env?: Record; readonly spawnerLayer?: Layer.Layer; readonly desktopSettingsLayer?: Layer.Layer; + readonly managedPolicyLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const layerEnvironment = layerEnvironmentFor(input.baseDir, env); @@ -142,6 +145,7 @@ function layer(input: { return DesktopServerExposure.layer.pipe( Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer), + Layer.provideMerge(input.managedPolicyLayer ?? DesktopManagedPolicy.layerTest()), Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(input.spawnerLayer ?? layerMockSpawner()), @@ -165,6 +169,7 @@ const withHarness = ( env: Record = {}, spawnerLayer?: Layer.Layer, desktopSettingsLayer?: Layer.Layer, + managedPolicyLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -179,6 +184,7 @@ const withHarness = ( env, ...(spawnerLayer ? { spawnerLayer } : {}), ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), + ...(managedPolicyLayer ? { managedPolicyLayer } : {}), }), ), ); @@ -234,6 +240,41 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("starts local-only, Serve off, when a managed policy keeps clients local", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* settings.setTailscaleServe({ enabled: true, port: Option.none() }); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "local-only"); + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "127.0.0.1"); + assert.isFalse(backendConfig.tailscaleServeEnabled); + // Choosing them again while the policy applies does not widen the backend either. + yield* serverExposure.setMode("network-accessible"); + yield* serverExposure.setTailscaleServeEnabled({ enabled: true }); + assert.equal((yield* serverExposure.backendConfig).bindHost, "127.0.0.1"); + assert.isFalse((yield* serverExposure.backendConfig).tailscaleServeEnabled); + // The saved choice returns once the policy allows it. + const saved = yield* settings.get; + assert.equal(saved.serverExposureMode, "network-accessible"); + assert.isTrue(saved.tailscaleServeEnabled); + }), + {}, + undefined, + undefined, + DesktopManagedPolicy.layerTest({ + schemaVersion: 1, + organization: "Example Corp", + limits: { clients: ["desktop"] }, + }), + ), + ); + it.effect("persists network-accessible mode and updates backend binding state", () => withHarness( lanNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index dde031c6aae14..78cd4c8c9c5aa 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -33,6 +33,7 @@ import * as Semaphore from "effect/Semaphore"; import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; // fork: tailnet-ownership @@ -586,6 +587,18 @@ export const make = Effect.gen(function* () { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), ); + // The server refuses to bind wider than the policy's clients allow, so network access or Serve + // saved before the policy would keep the backend from starting. The backend runs without them; + // the saved choice stays for when the policy allows it. + const remoteAllowed = yield* (yield* DesktopManagedPolicy.DesktopManagedPolicy).read.pipe( + Effect.map((found) => found?.document.limits?.clients?.includes("remote") ?? true), + Effect.orElseSucceed(() => false), + ); + const withinPolicy = (settings: DesktopAppSettings.DesktopSettings) => + remoteAllowed + ? settings + : { ...settings, serverExposureMode: "local-only" as const, tailscaleServeEnabled: false }; + const readCurrentState = (currentNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces) => Ref.get(stateRef).pipe( Effect.map((state) => @@ -611,8 +624,8 @@ export const make = Effect.gen(function* () { const settings = yield* desktopSettings.get; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ - requestedMode: settings.serverExposureMode, - settings, + requestedMode: withinPolicy(settings).serverExposureMode, + settings: withinPolicy(settings), port, networkInterfaces: currentNetworkInterfaces, advertisedHostOverride: config.desktopLanHostOverride, @@ -634,8 +647,8 @@ export const make = Effect.gen(function* () { }; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ - requestedMode: mode, - settings: nextSettings, + requestedMode: withinPolicy(nextSettings).serverExposureMode, + settings: withinPolicy(nextSettings), port: previous.port, networkInterfaces: currentNetworkInterfaces, advertisedHostOverride: config.desktopLanHostOverride, @@ -679,9 +692,12 @@ export const make = Effect.gen(function* () { }); } // Refuse before saving: the backend would only fail to claim the port on every launch. - const inspection = input.enabled - ? yield* inspectServe({ port: current.port, servePort }) - : null; + // A policy that keeps clients local saves the choice without applying it, so skip the check. + // fork: tailnet-ownership + const inspection = + input.enabled && remoteAllowed + ? yield* inspectServe({ port: current.port, servePort }) + : null; if (inspection !== null && REFUSED_TAILSCALE_SERVE_STATES.has(inspection.state)) { return yield* new DesktopTailscaleServeRefusedError({ port: servePort, @@ -707,7 +723,7 @@ export const make = Effect.gen(function* () { const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({ ...current, - tailscaleServeEnabled: result.settings.tailscaleServeEnabled, + tailscaleServeEnabled: withinPolicy(result.settings).tailscaleServeEnabled, tailscaleServePort: result.settings.tailscaleServePort, })); diff --git a/apps/desktop/src/compileCache.ts b/apps/desktop/src/compileCache.ts index abf8918df2af0..7bbd9adc7f407 100644 --- a/apps/desktop/src/compileCache.ts +++ b/apps/desktop/src/compileCache.ts @@ -2,16 +2,23 @@ import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import { enablePreparedServerCache } from "../../../scripts/lib/server-compile-cache.ts"; // Linux /tmp is shared; macOS and Windows temp dirs are per user. // AppImage mounts change paths each launch, so Node's path-keyed cache would miss and accumulate copies. try { - if (!process.env.APPIMAGE) { + if (!process.env.APPIMAGE || process.env.ELECTRON_RUN_AS_NODE === "1") { const cacheRoot = // oxlint-disable-next-line t3code/no-global-process-runtime -- Loads before any Effect runtime. process.platform === "linux" ? process.env.XDG_CACHE_HOME || NodePath.join(NodeOS.homedir(), ".cache") : NodeOS.tmpdir(); - NodeModule.enableCompileCache(NodePath.join(cacheRoot, "t3code", "compile-cache")); + const directory = NodePath.join(cacheRoot, "t3code", "compile-cache"); + const prepared = + process.env.ELECTRON_RUN_AS_NODE === "1" && + enablePreparedServerCache(process.argv[1] ?? "", directory); + if (!prepared && !process.env.APPIMAGE) { + NodeModule.enableCompileCache(directory); + } } } catch {} diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 27124d441b4e7..d698d5de55c1e 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,7 +40,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, - getRelayTelemetryEnabled, + installRelayTelemetryEnabled, getSystemLocale, getWindowFullscreenState, openExternal, @@ -95,7 +95,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); - yield* ipc.handleSync(getRelayTelemetryEnabled); + yield* installRelayTelemetryEnabled(); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index ed821165b40f7..eacc92c94996d 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,6 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { ManagedPolicyLimits } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { ManagedPolicyFileError } from "@t3tools/shared/managedPolicyFile"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; @@ -21,11 +23,14 @@ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfig import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; import { getLocalEnvironmentBootstraps, - getRelayTelemetryEnabled, + installRelayTelemetryEnabled, pickProjectFavicon, probeRemoteEditors, } from "./window.ts"; @@ -177,21 +182,54 @@ describe("getLocalEnvironmentBootstraps", () => { }); }); -describe("getRelayTelemetryEnabled", () => { +describe("installRelayTelemetryEnabled", () => { + // The renderer reads this over sync IPC, so the answer must not wait on the filesystem. + const relayTelemetryEnabled = ( + env: NodeJS.ProcessEnv, + policy?: Parameters[0], + ) => + Effect.gen(function* () { + const listeners = new Map(); + const ipcMain: DesktopIpc.DesktopIpcMain = { + removeHandler: () => undefined, + handle: () => undefined, + removeAllListeners: (channel) => void listeners.delete(channel), + on: (channel, listener) => void listeners.set(channel, listener), + }; + yield* installRelayTelemetryEnabled().pipe( + Effect.provideService(HostProcess.Environment, env), + Effect.provide( + Layer.mergeAll(DesktopIpc.layer(ipcMain), DesktopManagedPolicy.layerTest(policy)), + ), + ); + const event = { returnValue: undefined as unknown }; + listeners.get(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL)?.(event as never); + return event.returnValue; + }).pipe(Effect.scoped); + it.effect("reads the desktop host opt-out before returning the renderer policy", () => Effect.gen(function* () { + assert.isFalse(yield* relayTelemetryEnabled({ T3CODE_TELEMETRY_ENABLED: "false" })); + assert.isTrue(yield* relayTelemetryEnabled({})); + }), + ); + + it.effect("turns relay tracing off when the managed policy turns telemetry off", () => + Effect.gen(function* () { + const policy = (limits: ManagedPolicyLimits) => ({ + schemaVersion: 1 as const, + organization: "Example Corp", + limits, + }); + assert.isFalse(yield* relayTelemetryEnabled({}, policy({ telemetry: false }))); + assert.isTrue(yield* relayTelemetryEnabled({}, policy({ webhookTasks: false }))); + // A policy that cannot be read fails closed. assert.isFalse( - yield* getRelayTelemetryEnabled.handler().pipe( - Effect.provideService(HostProcess.Environment, { - T3CODE_TELEMETRY_ENABLED: "false", - }), + yield* relayTelemetryEnabled( + {}, + new ManagedPolicyFileError({ path: "/test", reason: "bad JSON" }), ), ); - assert.isTrue( - yield* getRelayTelemetryEnabled - .handler() - .pipe(Effect.provideService(HostProcess.Environment, {})), - ); }), ); }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 2278372cd4da5..925ce4ba00a72 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -27,6 +27,7 @@ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfig import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -72,13 +73,28 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); -export const getRelayTelemetryEnabled = DesktopIpc.makeSyncIpcMethod({ - channel: IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL, - result: Schema.Boolean, - handler: Effect.fn("desktop.ipc.window.getRelayTelemetryEnabled")(function* () { - return isRelayTracingEnabled(yield* HostProcess.Environment); - }), -}); +/** + * Relay traces leave the machine, so a policy with `telemetry: false`, or one that cannot be read, + * stops them. Read once here: a sync IPC handler cannot wait on the filesystem. + */ +export const installRelayTelemetryEnabled = Effect.fn("desktop.ipc.window.installRelayTelemetry")( + function* () { + const ipc = yield* DesktopIpc.DesktopIpc; + const enabled = + isRelayTracingEnabled(yield* HostProcess.Environment) && + (yield* (yield* DesktopManagedPolicy.DesktopManagedPolicy).read.pipe( + Effect.map((found) => found?.document.limits?.telemetry !== false), + Effect.orElseSucceed(() => false), + )); + yield* ipc.handleSync( + DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: () => Effect.succeed(enabled), + }), + ); + }, +); export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f7e1d8c0b9c12..7430b3679c145 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -51,6 +51,7 @@ import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; +import * as DesktopManagedPolicy from "./app/DesktopManagedPolicy.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; @@ -69,6 +70,7 @@ import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; +import * as DesktopUpdatePolicy from "./updates/DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; @@ -147,6 +149,7 @@ const layerDesktopFoundation = Layer.mergeAll( DesktopIdentityRenewal.layer.pipe(Layer.provideMerge(DesktopCloudflareAccess.layer)), // fork: identity-renewal OpenAtLogin.layer.pipe(Layer.provide(DesktopAppSettings.layer)), // fork: open-at-login MacPermissions.layer, + DesktopManagedPolicy.layer, DesktopState.layer, DesktopShutdown.layer, DesktopLegacyLocalStorage.layer, @@ -192,10 +195,10 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( const layerDesktopBackend = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), - Layer.provideMerge(DesktopShellEnvironment.layer), Layer.provideMerge(DesktopWslEnvironment.layer), Layer.provideMerge(DesktopWslServerTree.layer), Layer.provideMerge(DesktopTelemetryPublisher.layer), + Layer.provideMerge(DesktopShellEnvironment.layer), Layer.provideMerge(layerDesktopWindow), ); @@ -217,7 +220,13 @@ const layerDesktopApplication = Layer.mergeAll( layerDesktopSsh, ).pipe( Layer.provideMerge(layerDesktopSnapShot), - Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge( + DesktopUpdates.layer.pipe( + Layer.provide(DesktopUpdatePolicy.layer), + // The same layer as the foundation's, so the policy is still read once. + Layer.provide(DesktopManagedPolicy.layer), + ), + ), Layer.provideMerge(layerDesktopWslBackend), Layer.provideMerge(layerDesktopLocalEnvironmentAuth), ); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index c691ac15174d6..9528cd5d2e846 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -6,6 +6,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as BrowserSession from "../BrowserSession.ts"; +import { readManagedPolicyFile } from "@t3tools/shared/managedPolicyFile"; +import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; import * as BrowserImport from "./BrowserImport.ts"; import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -52,6 +54,7 @@ const withImporter = Effect.fnUntraced(function* () { Effect.provide( BrowserImport.layer.pipe( Layer.provide(layerRejectedBeforeSession), + Layer.provide(DesktopManagedPolicy.layerTest()), Layer.provide(layerEnvironment), Layer.provide(Layer.succeed(HostProcess.Platform, "darwin")), Layer.provide(Layer.succeed(HostProcess.ExecutablePath, "/Applications/T3 Code.app")), @@ -108,11 +111,46 @@ describe("BrowserImport.importCookies", () => { }) .pipe(Effect.flip); + assert.instanceOf(error, BrowserImport.BrowserImportFailedError); assert.equal(error.reason, "browserRunning"); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); +describe("BrowserImport.checkBrowserImportPolicy", () => { + it.effect("allows imports unless a readable policy turns them off, and fails closed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-policy-" }); + const outcome = (contents: string | undefined) => + Effect.gen(function* () { + const policyPath = `${dir}/policy.json`; + yield* fileSystem.remove(policyPath, { force: true }); + if (contents !== undefined) yield* fileSystem.writeFileString(policyPath, contents); + const readPolicy = readManagedPolicyFile(policyPath).pipe( + Effect.map((document) => document && { source: policyPath, document }), + ); + return yield* BrowserImport.checkBrowserImportPolicy(readPolicy).pipe( + Effect.match({ onFailure: (error) => error.message, onSuccess: () => "allowed" }), + ); + }); + const policy = (limits?: object) => + JSON.stringify({ schemaVersion: 1, organization: "Example Corp", limits }); + + assert.equal(yield* outcome(undefined), "allowed"); + assert.equal(yield* outcome(policy()), "allowed"); + assert.equal(yield* outcome(policy({ browserCookieImport: true })), "allowed"); + assert.equal( + yield* outcome(policy({ browserCookieImport: false })), + "Example Corp's policy does not allow importing browser cookies.", + ); + for (const unreadable of ["{ not json", policy({ notALimit: false })]) { + assert.include(yield* outcome(unreadable), "could not be read"); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + describe("BrowserImport.writeCookies", () => { it.effect("flushes the store after writing, and reports success if the flush fails", () => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index c567b27411c0c..0bf95cd641c5b 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -4,7 +4,11 @@ import type { BrowserImportSource, BrowserImportUnavailableReason, } from "@t3tools/contracts"; -import { BrowserImportFailureReason } from "@t3tools/contracts"; +import { + BrowserImportFailureReason, + type ManagedPolicyDocument, + managedPolicyDeniedMessage, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type { Session } from "electron"; import * as Effect from "effect/Effect"; @@ -16,7 +20,9 @@ import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { ManagedPolicyFileError } from "@t3tools/shared/managedPolicyFile"; +import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; import * as BrowserSession from "../BrowserSession.ts"; import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; import type { CookieReadResult } from "./CookieDatabase.ts"; @@ -47,6 +53,45 @@ export class BrowserImportFailedError extends Schema.TaggedError()( + "BrowserImportPolicyError", + { + policyPath: Schema.String, + /** Absent when the policy could not be read or decoded. */ + organization: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.organization === undefined + ? `The managed policy at ${this.policyPath} could not be read, so importing browser cookies is not allowed.` + : managedPolicyDeniedMessage(this.organization, "importing browser cookies"); + } +} + +/** + * A policy that cannot be read or decoded denies the import: cookies are credentials, and the + * server refuses to start on the same policy anyway. + */ +export const checkBrowserImportPolicy = Effect.fn("BrowserImport.checkPolicy")(function* ( + readPolicy: Effect.Effect< + { readonly source: string; readonly document: ManagedPolicyDocument } | undefined, + ManagedPolicyFileError, + R + >, +) { + const policy = yield* readPolicy.pipe( + Effect.mapError((cause) => new BrowserImportPolicyError({ policyPath: cause.path, cause })), + ); + if (policy?.document.limits?.browserCookieImport === false) { + return yield* new BrowserImportPolicyError({ + policyPath: policy.source, + organization: policy.document.organization, + }); + } +}); + export class BrowserCookieWriteError extends Schema.TaggedError()( "BrowserCookieWriteError", { @@ -69,7 +114,7 @@ export class BrowserImport extends Context.Service< readonly scope: string; readonly persistent: boolean; readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; - }) => Effect.Effect; + }) => Effect.Effect; } >()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} @@ -150,6 +195,7 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( /** @public */ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; + const managedPolicy = yield* DesktopManagedPolicy.DesktopManagedPolicy; const platform = yield* HostProcess.Platform; const executablePath = yield* HostProcess.ExecutablePath; const platformServices = yield* Effect.context< @@ -177,6 +223,7 @@ export const make = Effect.gen(function* BrowserImportMake() { readonly persistent: boolean; readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; }) { + yield* checkBrowserImportPolicy(managedPolicy.read); const definition = BROWSER_IMPORT_SOURCES.find( (candidate) => candidate.id === input.input.sourceId, ); diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index df41a9821bf96..6ee471c211324 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -172,6 +172,7 @@ describe("BrowserSession", () => { "clipboard-sanitized-write", "geolocation", "fullscreen", + "pointerLock", "loopback-network", ]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index f8f23aca6afa7..a427708a37080 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -24,6 +24,7 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "geolocation", // DesktopWindow confines HTML fullscreen to the guest's webview. "fullscreen", + "pointerLock", // Loopback requests already succeed in Electron 44; permission-aware sign-in widgets query first. "loopback-network", // Deny local-fonts to untrusted previews: it exposes installed-font fingerprints and file bytes. diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 251e4ff14642c..1fdb427526328 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -227,7 +227,7 @@ describe("CDP relay extra sessions", () => { }); describe("CDP relay downloads", () => { - it("applies the server's download directory and reports downloads on the root session", async () => { + it("captures preview downloads without changing the default browser context", async () => { const send = vi.fn(async () => ({})); const directories: Array = []; const written: Array> = []; @@ -250,17 +250,110 @@ describe("CDP relay downloads", () => { ); await settle(); expect(directories).toEqual(["/srv/artifacts"]); - expect(send).toHaveBeenCalledWith( - "Browser.setDownloadBehavior", - { behavior: "allowAndName", downloadPath: "/srv/artifacts", eventsEnabled: true }, - undefined, - ); + expect(send.mock.calls).toEqual([ + ["Page.setDownloadBehavior", { behavior: "default" }, undefined], + ]); written.length = 0; - relay.event("Browser.downloadWillBegin", { guid: "g1", suggestedFilename: "r.csv" }, ""); + relay.event("Page.downloadWillBegin", { guid: "g1", suggestedFilename: "r.csv" }, ""); + relay.event("Page.downloadProgress", { guid: "g1", state: "completed" }, ""); expect(written).toEqual([ { method: "Browser.downloadWillBegin", params: { guid: "g1", suggestedFilename: "r.csv" } }, + { method: "Browser.downloadProgress", params: { guid: "g1", state: "completed" } }, + ]); + }); + + it("keeps denial scoped to the preview and restores Electron's save handling when allowed", async () => { + const send = vi.fn(async () => ({})); + const directories: Array = []; + const relay = makeRelay( + makeTarget(send, (directory) => directories.push(directory)), + () => {}, + ); + for (const [id, behavior] of ["deny", "allowAndName"].entries()) { + relay.receive( + JSON.stringify({ + id, + method: "Browser.setDownloadBehavior", + params: { behavior, downloadPath: "/srv/artifacts" }, + }), + ); + await settle(); + } + expect(directories).toEqual([null, "/srv/artifacts"]); + expect(send.mock.calls).toEqual([ + ["Page.setDownloadBehavior", { behavior: "deny" }, undefined], + ["Page.setDownloadBehavior", { behavior: "default" }, undefined], ]); }); + + it("does not publish download events when the connection has disabled them", async () => { + const written: Array> = []; + const relay = makeRelay(makeTarget(vi.fn(async () => ({}))), (raw) => + written.push(JSON.parse(raw)), + ); + relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); + relay.receive( + JSON.stringify({ + id: 2, + method: "Browser.setDownloadBehavior", + params: { behavior: "allowAndName", downloadPath: "/srv/artifacts", eventsEnabled: false }, + }), + ); + await settle(); + written.length = 0; + relay.event("Page.downloadWillBegin", { guid: "g1" }, ""); + relay.event("Page.downloadProgress", { guid: "g1", state: "completed" }, ""); + expect(written).toEqual([]); + }); + + it.each([false, true])( + "keeps the previous download configuration (events enabled: %s) when configuration fails", + async (enabled) => { + const send = vi + .fn(async () => ({})) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error("Page command rejected")); + const written: Array> = []; + let directory: string | null = null; + const relay = makeRelay( + makeTarget(send, (value) => { + directory = value; + }), + (raw) => written.push(JSON.parse(raw)), + ); + relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); + for (const [id, eventsEnabled] of [enabled, !enabled].entries()) { + relay.receive( + JSON.stringify({ + id: id + 2, + method: "Browser.setDownloadBehavior", + params: { + behavior: id === 1 && enabled ? "deny" : "allowAndName", + downloadPath: id === 0 ? "/srv/artifacts" : "/srv/new-artifacts", + eventsEnabled, + }, + }), + ); + await settle(); + } + expect(written).toContainEqual({ + id: 3, + error: { code: -32000, message: "Page command rejected" }, + }); + expect(directory).toBe("/srv/artifacts"); + written.length = 0; + relay.event("Page.downloadWillBegin", { guid: "g1" }, ""); + relay.event("Page.downloadProgress", { guid: "g1", state: "completed" }, ""); + expect(written).toEqual( + enabled + ? [ + { method: "Browser.downloadWillBegin", params: { guid: "g1" } }, + { method: "Browser.downloadProgress", params: { guid: "g1", state: "completed" } }, + ] + : [], + ); + }, + ); }); describe("CDP relay payload budget", () => { diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 1176aa01ba0ba..4eba465a964a8 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -102,6 +102,7 @@ export function createCdpRelayConnection( // enabled Runtime domain survives a disconnect. Reset it once so the new client hears // existing default document contexts; Runtime.enable alone does not report them again. let runtimeReset: Promise | undefined; + let downloadEventsEnabled = false; /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); let sessionSequence = 0; @@ -126,9 +127,15 @@ export function createCdpRelayConnection( case "Browser.setDownloadBehavior": { const directory = command.params?.["downloadPath"]; const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string"; + // Browser-scoped behavior targets Electron's app session; preview paths belong to will-download. + const result = await target.send( + "Page.setDownloadBehavior", + { behavior: command.params?.["behavior"] === "deny" ? "deny" : "default" }, + undefined, + ); target.setDownloadDirectory(allowed ? directory : null); - const { browserContextId: _ignored, ...params } = command.params ?? {}; - return target.send("Browser.setDownloadBehavior", params, undefined); + downloadEventsEnabled = command.params?.["eventsEnabled"] === true; + return result; } case "Browser.getVersion": return { @@ -243,9 +250,10 @@ export function createCdpRelayConnection( }, event: (method, params, sessionId) => { if (!attached) return; + if (method.startsWith("Page.download")) method = method.replace("Page.", "Browser."); // Download events are the browser's; Playwright listens on its root session. if (method.startsWith("Browser.download")) { - send({ method, params }); + if (downloadEventsEnabled) send({ method, params }); return; } // Electron reports the page's own events with an empty session id. diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 3168788bebe9b..2e6e54a5293a7 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -917,12 +917,14 @@ describe("DesktopBrowserHost", () => { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); const paths: Array = []; const item = { setSavePath: (path: string) => void paths.push(path), } as unknown as Electron.DownloadItem; expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false); + const sent = debuggee.nextSend(); yield* host.handleCommandLine( encodeJson({ type: "cdp", @@ -934,10 +936,15 @@ describe("DesktopBrowserHost", () => { }), }), ); - debuggee.emit("Browser.downloadWillBegin", { guid: "guid-1", suggestedFilename: "r.csv" }); + yield* Effect.promise(() => sent); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + debuggee.emit("Page.downloadWillBegin", { guid: "guid-1", suggestedFilename: "r.csv" }); + expect(host.placeDownload(makeDebuggee().tab.webContents, item)).toBe(false); expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(true); expect(paths).toEqual(["/srv/downloads/guid-1"]); - }), + expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false); + }).pipe(Effect.scoped), ); it.effect("tells a download the person clicked from one the agent's input started", () => diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 5a2b5463fc674..3ac6c6d73caec 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -302,9 +302,7 @@ export const make = Effect.gen(function* () { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; const send = (method: string, params: Record, sessionId?: string) => - sessionId === undefined - ? sendPageCommand(webContents, debuggee, method, params) - : debuggee.sendCommand(method, params, sessionId); + sendPageCommand(webContents, debuggee, method, params, sessionId); const detachedOwners = new Set(); /** Viewers and recordings stream; the agent's own sessions never do. */ const streamingOwners = new Set(); @@ -440,7 +438,7 @@ export const make = Effect.gen(function* () { focusReleasing: false, screencasts: new Set(), onMessage: (_event, method, params, sessionId) => { - if (method === "Browser.downloadWillBegin") { + if (method === "Page.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; tab.pendingDownloadGuid = typeof guid === "string" ? guid : null; } diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index b531182c92959..579b564f59aa1 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -1,3 +1,5 @@ +import * as NodeVM from "node:vm"; + import { describe, expect, it, vi } from "vite-plus/test"; import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts"; @@ -42,6 +44,149 @@ describe("preview guest input", () => { expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything()); }); + it.each([ + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "restore", + }, + { + sessionId: "child-frame", + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "restore", + }, + { + sessionId: undefined, + method: "Input.dispatchTouchEvent", + type: "touchStart", + outcome: "restore", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "other-field", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "removed", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "failure", + }, + ])( + "preserves host focus for $method in $sessionId with $outcome", + async ({ sessionId, method, type, outcome }) => { + const document = { activeElement: {} as object }; + const composer = { + isConnected: true, + focus: () => { + document.activeElement = composer; + }, + }; + const preview = { tagName: "WEBVIEW", getWebContentsId: () => 7 }; + const otherField = { tagName: "INPUT" }; + document.activeElement = composer; + const context = NodeVM.createContext({ document }); + const host = { + isDestroyed: () => false, + executeJavaScript: async (script: string) => NodeVM.runInContext(script, context), + }; + const guest = { id: 7, focusedFrame: null, hostWebContents: host }; + const debuggee = { + sendCommand: async () => { + document.activeElement = outcome === "other-field" ? otherField : preview; + if (outcome === "removed") composer.isConnected = false; + if (outcome === "failure") throw new Error("Pointer failed"); + return {}; + }, + }; + const result = sendPageCommand( + guest as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + method, + { type, x: 10, y: 10, button: "left" }, + sessionId, + ); + if (outcome === "failure") await expect(result).rejects.toThrow("Pointer failed"); + else await result; + expect(document.activeElement).toBe( + outcome === "other-field" ? otherField : outcome === "removed" ? preview : composer, + ); + expect(Object.keys(context)).toEqual(["document"]); + }, + ); + + it.each([ + { + method: "Input.dispatchMouseEvent", + press: "mousePressed", + release: "mouseReleased", + failPress: false, + }, + { + method: "Input.dispatchTouchEvent", + press: "touchStart", + release: "touchEnd", + failPress: false, + }, + { + method: "Input.dispatchMouseEvent", + press: "mousePressed", + release: "mouseReleased", + failPress: true, + }, + ])( + "keeps $method releases behind presses waiting for host focus, failed press: $failPress", + async ({ method, press, release, failPress }) => { + const captureStarted = Promise.withResolvers(); + const finishCapture = Promise.withResolvers(); + const events: Array = []; + const guest = { + id: 7, + focusedFrame: null, + hostWebContents: { + isDestroyed: () => false, + executeJavaScript: async () => { + captureStarted.resolve(); + await finishCapture.promise; + }, + }, + }; + const debuggee = { + sendCommand: async (_method: string, params: Record) => { + events.push(params["type"]); + if (failPress && params["type"] === press) throw new Error("Press failed"); + return {}; + }, + }; + const send = (type: string) => + sendPageCommand( + guest as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + method, + { type }, + ); + const pressed = send(press); + await captureStarted.promise; + const released = send(release); + finishCapture.resolve(); + const results = await Promise.allSettled([pressed, released]); + expect(results.map((result) => result.status)).toEqual([ + failPress ? "rejected" : "fulfilled", + "fulfilled", + ]); + expect(events).toEqual([press, release]); + }, + ); + it("refuses input when a cross-site frame holds the unfocused page's focus", async () => { const guest = makeGuest({ focused: false, focusedDocument: false }); await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow( diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 42201b48ed61c..d3ccf29c76ed7 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -92,13 +92,78 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => { return reply.result?.value; }; +let focusSequence = 0; + +/** A CDP pointer press focuses the guest's native view even with focus emulation enabled. */ +const sendPointerPress = async ( + webContents: Electron.WebContents, + debuggee: Electron.Debugger, + method: string, + params: Record, + sessionId: string | undefined, +) => { + const host = webContents.hostWebContents; + if (!host || host.isDestroyed() || webContents.focusedFrame) { + return debuggee.sendCommand(method, params, sessionId); + } + const key = JSON.stringify(`__t3PreviewFocus${++focusSequence}`); + await host.executeJavaScript(`(() => { + const active = document.activeElement; + if (!active || active === document.body || active.tagName === "WEBVIEW") return; + globalThis[${key}] = () => { + delete globalThis[${key}]; + const current = document.activeElement; + if (active.isConnected && current?.tagName === "WEBVIEW" && + current.getWebContentsId() === ${webContents.id}) active.focus({ preventScroll: true }); + }; + })()`); + try { + return await debuggee.sendCommand(method, params, sessionId); + } finally { + if (!host.isDestroyed()) { + await host.executeJavaScript(`globalThis[${key}]?.()`); + } + } +}; + +const pointerCommands = new WeakMap>(); + +/** Focus capture crosses processes; later pointer events must not overtake the press. */ +const sendPointerCommand = async ( + webContents: Electron.WebContents, + debuggee: Electron.Debugger, + method: string, + params: Record, + sessionId: string | undefined, +) => { + const previous = pointerCommands.get(webContents) ?? Promise.resolve(); + const current = previous + .catch(() => {}) + .then(() => + params["type"] === "mousePressed" || params["type"] === "touchStart" + ? sendPointerPress(webContents, debuggee, method, params, sessionId) + : debuggee.sendCommand(method, params, sessionId), + ); + pointerCommands.set(webContents, current); + try { + return await current; + } finally { + if (pointerCommands.get(webContents) === current) pointerCommands.delete(webContents); + } +}; + /** Sends one command to the guest's page, keeping reloads and input inside it. */ export const sendPageCommand = async ( webContents: Electron.WebContents, debuggee: Electron.Debugger, method: string, params: Record, + sessionId?: string, ): Promise => { + if (method === "Input.dispatchMouseEvent" || method === "Input.dispatchTouchEvent") { + return sendPointerCommand(webContents, debuggee, method, params, sessionId); + } + if (sessionId !== undefined) return debuggee.sendCommand(method, params, sessionId); // Electron's debugger routes Page.reload on a webview to its embedder window. if (method === "Page.reload") { if (params["ignoreCache"] === true) webContents.reloadIgnoringCache(); diff --git a/apps/desktop/src/serverCompileCache.ts b/apps/desktop/src/serverCompileCache.ts new file mode 100644 index 0000000000000..01e71b1aa8720 --- /dev/null +++ b/apps/desktop/src/serverCompileCache.ts @@ -0,0 +1 @@ +export { generatePreparedServerCache } from "../../../scripts/lib/server-compile-cache.ts"; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 33325d9de9907..79344567fcd6c 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -3,11 +3,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeOS from "node:os"; @@ -19,6 +23,15 @@ import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; vi.mock("node:os", { spy: true }); const textEncoder = new TextEncoder(); +const cacheCodec = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Number, + key: Schema.String, + environment: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), + }), +); +const encodeCache = Schema.encodeSync(cacheCodec); +const decodeCache = Schema.decodeUnknownEffect(cacheCodec); function envOutput(values: Readonly>): string { return Object.entries(values) @@ -69,18 +82,28 @@ function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly userShell?: string; + readonly stateDir?: string; + readonly use?: ( + service: DesktopShellEnvironment.DesktopShellEnvironment["Service"], + ) => Effect.Effect; readonly handler: ( command: ChildProcess.Command, ) => string | { output: string; exitCode: number }; readonly failure?: PlatformError.PlatformError; readonly processSpawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; }) { - const layerEnvironment = Layer.succeed( + const layerEnvironment = Layer.effect( DesktopEnvironment.DesktopEnvironment, - DesktopEnvironment.DesktopEnvironment.of({ - platform: input.platform, - } as DesktopEnvironment.DesktopEnvironment["Service"]), - ); + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = input.stateDir ?? (yield* fs.makeTempDirectoryScoped()); + return DesktopEnvironment.DesktopEnvironment.of({ + platform: input.platform, + stateDir, + homeDirectory: "/test-home", + } as DesktopEnvironment.DesktopEnvironment["Service"]); + }), + ).pipe(Layer.provide(NodeServices.layer)); const layerSpawner = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { @@ -97,8 +120,9 @@ function runShellEnvironment(input: { const program = Effect.gen(function* () { const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; - const prepared = yield* shellEnvironment.installIntoProcess; - assert.equal(yield* shellEnvironment.installIntoProcess, prepared); + if (input.use) return yield* input.use(shellEnvironment); + const prepared = yield* shellEnvironment.awaitDiscovery; + assert.equal(yield* shellEnvironment.awaitDiscovery, prepared); return prepared; }).pipe( Effect.provide( @@ -121,6 +145,247 @@ function runShellEnvironment(input: { } describe("DesktopShellEnvironment", () => { + it.effect("does not wait for discovery when the cache is missing", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + yield* runShellEnvironment({ + env: { SHELL: "/test-shell", PATH: "/usr/bin" }, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(makeProcess(envOutput({ PATH: "/fresh/bin" }))), + ), + ), + use: (service) => + Effect.gen(function* () { + const startup = yield* service.installIntoProcess.pipe(Effect.forkChild); + yield* Deferred.await(started); + assert.isDefined(startup.pollUnsafe()); + yield* Deferred.succeed(release, undefined); + return yield* service.awaitDiscovery; + }), + }); + }), + ); + + it.effect( + "reuses cached paths immediately and streams fresh discovery without keeping removed paths", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner.pipe( + Effect.provide(NodeServices.layer), + ); + const executable = yield* HostProcess.ExecutablePath; + const first = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + yield* runShellEnvironment({ + env: first, + stateDir, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/old/bin:/usr/bin", + __MISE_DIFF: "old-mise", + SSH_AUTH_SOCK: "/old-session.sock", + JAVA_HOME: "/old/java", + SSL_CERT_FILE: "/old/cert.pem", + }), + }); + const cachePath = `${stateDir}/shell-environment-cache.json`; + const cached = yield* fs.readFileString(cachePath).pipe(Effect.flatMap(decodeCache)); + assert.equal(cached.environment.PATH, "/old/bin:/usr/bin"); + assert.notProperty(cached.environment, "SSH_AUTH_SOCK"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/launcher/bin", + SSH_AUTH_SOCK: "/current-session.sock", + }; + const source = [ + "const values = { PATH: '/fresh/bin:' + process.env.PATH, __MISE_DIFF: 'fresh-mise', JAVA_HOME: process.env.JAVA_HOME, SSL_CERT_FILE: process.env.SSL_CERT_FILE };", + "for (const [name, value] of Object.entries(values)) {", + " if (value !== undefined) process.stdout.write('__T3CODE_ENV_' + name + '_START__\\n' + value + '\\n__T3CODE_ENV_' + name + '_END__\\n');", + "}", + ].join("\n"); + let calls = 0; + yield* runShellEnvironment({ + env, + stateDir, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make((command) => { + calls++; + return Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen( + command._tag === "StandardCommand" + ? spawner.spawn(ChildProcess.make(executable, ["-e", source], command.options)) + : Effect.succeed(makeProcess("")), + ), + ); + }), + use: (service) => + Effect.gen(function* () { + yield* service.installIntoProcess; + yield* service.installIntoProcess; + assert.equal(env.PATH, "/old/bin:/usr/bin:/launcher/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/current-session.sock"); + const replay = yield* Stream.runHead(service.updates); + assert.isTrue(Option.isSome(replay)); + if (Option.isSome(replay)) + assert.equal(replay.value.environment.__MISE_DIFF, "old-mise"); + yield* Deferred.await(started); + assert.equal(calls, 1); + yield* Deferred.succeed(release, undefined); + yield* service.awaitDiscovery; + assert.equal(env.PATH, "/fresh/bin:/launcher/bin"); + assert.equal(env.__MISE_DIFF, "fresh-mise"); + assert.isUndefined(env.JAVA_HOME); + assert.isUndefined(env.SSL_CERT_FILE); + const fresh = yield* Stream.runHead(service.updates); + assert.isTrue(Option.isSome(fresh)); + if (Option.isSome(fresh)) { + assert.equal(fresh.value.environment.PATH, env.PATH); + assert.isTrue(fresh.value.discovered); + } + return true; + }), + }); + const refreshed = yield* fs.readFileString(cachePath).pipe(Effect.flatMap(decodeCache)); + assert.equal(refreshed.environment.PATH, "/fresh/bin:/launcher/bin"); + assert.notProperty(refreshed.environment, "JAVA_HOME"); + assert.notProperty(refreshed.environment, "SSL_CERT_FILE"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each([ + "not json", + '{"version":2,"key":"old","environment":{"PATH":"/bad/bin"}}', + '{"version":1,"key":"other-shell","environment":{"PATH":"/bad/bin"}}', + ])("ignores an unusable cache and still starts before discovery: %s", (contents) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(`${stateDir}/shell-environment-cache.json`, contents); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + yield* runShellEnvironment({ + env, + stateDir, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(makeProcess(envOutput({ PATH: "/fresh/bin" }))), + ), + ), + use: (service) => + Effect.gen(function* () { + const startup = yield* service.installIntoProcess.pipe(Effect.forkChild); + yield* Deferred.await(started); + assert.isDefined(startup.pollUnsafe()); + assert.equal(env.PATH, "/usr/bin"); + yield* Deferred.succeed(release, undefined); + return yield* service.awaitDiscovery; + }), + }); + assert.equal(env.PATH, "/fresh/bin:/usr/bin"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "retains the last successful cache on refresh failure and ignores session values in the cache", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const contents = encodeCache({ + version: 1, + key: '["darwin","/test-home","/bin/zsh",""]', + environment: { + PATH: "/cached/bin", + __MISE_DIFF: "cached-mise", + JAVA_HOME: "/cached/java", + NODE_EXTRA_CA_CERTS: "/cached/ca.pem", + SSL_CERT_FILE: "/cached/cert.pem", + SSL_CERT_DIR: "/cached/certs", + SSH_AUTH_SOCK: "/stale.sock", + NODE_OPTIONS: "--bad", + }, + }); + const cachePath = `${stateDir}/shell-environment-cache.json`; + yield* fs.writeFileString(cachePath, contents); + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + __MISE_DIFF: "launcher-mise", + JAVA_HOME: "/launcher/java", + NODE_EXTRA_CA_CERTS: "/launcher/ca.pem", + SSL_CERT_FILE: "", + SSH_AUTH_SOCK: "/current.sock", + }; + const prepared = yield* runShellEnvironment({ + env, + stateDir, + platform: "darwin", + handler: () => "", + use: (service) => + Effect.gen(function* () { + yield* service.installIntoProcess; + const replay = yield* Stream.runHead(service.updates); + assert.isTrue(Option.isSome(replay)); + if (Option.isSome(replay)) { + assert.equal(replay.value.environment.JAVA_HOME, "/launcher/java"); + assert.equal(replay.value.environment.NODE_EXTRA_CA_CERTS, "/launcher/ca.pem"); + } + return yield* service.awaitDiscovery; + }), + }); + assert.isFalse(prepared); + assert.equal(env.PATH, "/cached/bin:/usr/bin"); + assert.equal(env.__MISE_DIFF, "cached-mise"); + assert.equal(env.JAVA_HOME, "/launcher/java"); + assert.equal(env.NODE_EXTRA_CA_CERTS, "/launcher/ca.pem"); + assert.equal(env.SSL_CERT_FILE, ""); + assert.equal(env.SSL_CERT_DIR, "/cached/certs"); + assert.equal(env.SSH_AUTH_SOCK, "/current.sock"); + assert.isUndefined(env.NODE_OPTIONS); + assert.equal(yield* fs.readFileString(cachePath), contents); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("cancels background discovery when the application scope closes", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + yield* runShellEnvironment({ + env: { SHELL: "/bin/zsh", PATH: "/usr/bin" }, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(stopped, undefined)), + ), + ), + use: (service) => + Effect.gen(function* () { + yield* service.installIntoProcess; + yield* Deferred.await(started); + return true; + }), + }); + yield* Deferred.await(stopped); + }), + ); + it.effect( "captures the login-shell PATH when startup writes more than a pipe buffer to stderr", () => diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 255eb202df68a..556f7090b422e 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,4 +1,6 @@ +import { DesktopShellEnvironmentSnapshot, ShellEnvironmentPatch } from "@t3tools/contracts"; import { + applyShellEnvironment, buildEnvironmentCaptureCommand, listLoginShellCandidates, setWindowsPath, @@ -6,6 +8,11 @@ import { import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -68,8 +75,10 @@ export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErr export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { - // Reuses the installation result; false leaves server hydration enabled. + // Desktop owns discovery, including on cache misses; the backend receives updates over fd4. readonly installIntoProcess: Effect.Effect; + readonly awaitDiscovery: Effect.Effect; + readonly updates: Stream.Stream; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} @@ -97,6 +106,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "T3CODE_TELEMETRY_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", + "JAVA_HOME", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; @@ -244,6 +254,7 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( readonly probe: DesktopShellEnvironmentProbe; readonly command: string; readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; readonly timeout: Duration.Duration; readonly shell?: boolean; }): Effect.fn.Return { @@ -253,6 +264,8 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { shell: input.shell ?? false, + env: input.env, + extendEnv: false, stdin: "ignore", stdout: "pipe", stderr: "ignore", @@ -308,6 +321,7 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( const readLoginShellEnvironment = ( shell: string, names: ReadonlyArray, + env: NodeJS.ProcessEnv, ): Effect.Effect => names.length === 0 ? Effect.succeed({}) @@ -315,20 +329,24 @@ const readLoginShellEnvironment = ( probe: "login-shell", command: shell, args: ["-ilc", buildEnvironmentCaptureCommand(shell, names)], + env, timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); -const readLaunchctlPath = runCommandOutput({ - probe: "launchctl-path", - command: "/bin/launchctl", - args: ["getenv", "PATH"], - timeout: LAUNCHCTL_TIMEOUT, -}).pipe(Effect.map(trimNonEmpty)); +const readLaunchctlPath = (env: NodeJS.ProcessEnv) => + runCommandOutput({ + env, + probe: "launchctl-path", + command: "/bin/launchctl", + args: ["getenv", "PATH"], + timeout: LAUNCHCTL_TIMEOUT, + }).pipe(Effect.map(trimNonEmpty)); const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( function* ( names: ReadonlyArray, options: WindowsProbeOptions, + env: NodeJS.ProcessEnv, ): Effect.fn.Return { if (names.length === 0) return {}; @@ -345,6 +363,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, + env, timeout: LOGIN_SHELL_TIMEOUT, }); const environment = extractEnvironment(output, names); @@ -363,8 +382,8 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin ): Effect.fn.Return { const [noProfile, profile] = yield* Effect.all( [ - readWindowsEnvironment(["PATH"], { loadProfile: false }), - readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }), + readWindowsEnvironment(["PATH"], { loadProfile: false }, config.env), + readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }, config.env), ], { concurrency: 2 }, ); @@ -402,14 +421,14 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) { Object.assign( shellEnvironment, - yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), + yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env), ); if (shellEnvironment.PATH) break; } const launchctlPath = config.platform === "darwin" && !shellEnvironment.PATH - ? yield* readLaunchctlPath + ? yield* readLaunchctlPath(config.env) : Option.none(); const mergedPath = mergePaths(config.platform, [ trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), @@ -450,6 +469,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", + "JAVA_HOME", "T3CODE_TELEMETRY_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { @@ -512,24 +532,140 @@ const installShellEnvironment = ( return Effect.succeed(false); }; +const ShellEnvironmentCache = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + key: Schema.String, + environment: ShellEnvironmentPatch, + }), +); +const encodeCacheKey = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); +const decodeCache = Schema.decodeUnknownEffect(ShellEnvironmentCache); +const encodeCache = Schema.encodeEffect(ShellEnvironmentCache); +const CACHED_ENV_NAMES = [ + "PATH", + "__MISE_DIFF", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "JAVA_HOME", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", +] as const; + /** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = - yield* Effect.cached( - installShellEnvironment({ - env: process.env, - platform: environment.platform, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), - ), + const scope = yield* Scope.Scope; + const env = process.env; + const inherited = { ...env }; + const key = encodeCacheKey([ + environment.platform, + environment.homeDirectory, + inherited.SHELL ?? "", + inherited.ZDOTDIR ?? "", + ]); + const cachePath = path.join(environment.stateDir, "shell-environment-cache.json"); + const changes = yield* PubSub.unbounded(); + const latest = yield* Ref.make>(Option.none()); + const discovered = yield* Deferred.make(); + const publish = Effect.fn("desktop.shellEnvironment.publish")(function* ( + patch: ShellEnvironmentPatch, + discovered = false, + ) { + applyShellEnvironment(env, patch, environment.platform); + const message: DesktopShellEnvironmentSnapshot = { + version: 1, + type: "shellEnvironment", + discovered, + platform: environment.platform, + environment: patch, + }; + yield* Ref.set(latest, Option.some(message)); + yield* PubSub.publish(changes, message); + }); + const refresh = Effect.gen(function* () { + // Refresh from the inherited baseline so removed cached paths and values do not survive. + const fresh = { ...inherited }; + const prepared = yield* installShellEnvironment({ env: fresh, platform: environment.platform }); + const patch = Object.fromEntries( + Object.keys(ShellEnvironmentPatch.fields).map((name) => [name, fresh[name] ?? null]), ); - - return DesktopShellEnvironment.of({ installIntoProcess }); + if (prepared || Option.isNone(yield* Ref.get(latest))) yield* publish(patch, prepared); + if (prepared) { + const cached = Object.fromEntries( + CACHED_ENV_NAMES.flatMap((name) => (fresh[name] ? [[name, fresh[name]]] : [])), + ); + const persist = Effect.gen(function* () { + const encoded = yield* encodeCache({ version: 1, key, environment: cached }); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(cachePath + ".tmp", encoded, { mode: 0o600 }); + yield* fileSystem.rename(cachePath + ".tmp", cachePath); + }); + yield* persist.pipe( + Effect.catch(() => Effect.logWarning("Could not cache shell environment")), + ); + } + return prepared; + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.refresh"), + Effect.onExit((exit) => Deferred.done(discovered, exit)), + ); + const installIntoProcess = yield* Effect.cached( + Effect.gen(function* () { + const cache = yield* fileSystem + .readFileString(cachePath) + .pipe(Effect.flatMap(decodeCache), Effect.option); + if (Option.isSome(cache) && cache.value.key === key) { + const cached = Object.fromEntries( + CACHED_ENV_NAMES.flatMap((name) => { + const value = cache.value.environment[name]; + // mise's metadata must describe the cached PATH entries restored alongside it. + if (name !== "PATH" && name !== "__MISE_DIFF" && inherited[name] !== undefined) + return []; + return typeof value === "string" ? [[name, value]] : []; + }), + ); + const baseline = { ...inherited }; + const cachedPath = cached.PATH; + if (cachedPath) { + cached.PATH = Option.getOrElse( + mergePaths(environment.platform, [Option.some(cachedPath), readEnvPath(inherited)]), + () => cachedPath, + ); + } + applyShellEnvironment(baseline, cached, environment.platform); + yield* publish( + Object.fromEntries( + Object.keys(ShellEnvironmentPatch.fields).map((name) => [name, baseline[name] ?? null]), + ), + ); + } + yield* Effect.forkIn(refresh, scope); + return true; + }).pipe(Effect.withSpan("desktop.shellEnvironment.installIntoProcess")), + ); + const updates = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* Ref.get(latest); + return Stream.concat( + Option.isSome(snapshot) ? Stream.make(snapshot.value) : Stream.empty, + Stream.fromSubscription(subscription), + ); + }), + ); + return DesktopShellEnvironment.of({ + installIntoProcess, + awaitDiscovery: installIntoProcess.pipe(Effect.andThen(Deferred.await(discovered))), + updates, + }); }); export const layer = Layer.effect(DesktopShellEnvironment, make); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 0af9950afcb07..551480df3a97b 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; +import * as Context from "effect/Context"; +import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -8,8 +10,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as Stream from "effect/Stream"; // fork: machine-updates import * as Fiber from "effect/Fiber"; // fork: machine-updates import type * as Electron from "electron"; @@ -20,6 +22,7 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts"; import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; const layerHistory = Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { register: () => Effect.void, @@ -102,6 +105,11 @@ describe("DesktopTelemetryPublisher", () => { }), ), layerHistory, + Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { + installIntoProcess: Effect.succeed(true), + awaitDiscovery: Effect.succeed(true), + updates: Stream.empty, + }), ), ), ), @@ -110,39 +118,66 @@ describe("DesktopTelemetryPublisher", () => { ), ); - it.effect("stops when its scope closes during an Electron telemetry sample", () => - Effect.gen(function* () { - const pollStarted = yield* Deferred.make(); - const blockPoll = yield* Deferred.make(); - const layerPower = Layer.succeed( - ElectronPowerMonitor.ElectronPowerMonitor, - ElectronPowerMonitor.ElectronPowerMonitor.of({ - isOnBatteryPower: Effect.succeed(false), - getSystemIdleTime: Deferred.succeed(pollStarted, undefined).pipe( - Effect.andThen(Deferred.await(blockPoll)), - Effect.as(0), + it.effect( + "forwards shell discovery while a telemetry sample is pending and stops on scope close", + () => + Effect.gen(function* () { + const pollStarted = yield* Deferred.make(); + const blockPoll = yield* Deferred.make(); + const layerPower = Layer.succeed( + ElectronPowerMonitor.ElectronPowerMonitor, + ElectronPowerMonitor.ElectronPowerMonitor.of({ + isOnBatteryPower: Effect.succeed(false), + getSystemIdleTime: Deferred.succeed(pollStarted, undefined).pipe( + Effect.andThen(Deferred.await(blockPoll)), + Effect.as(0), + ), + getSystemIdleState: () => Effect.succeed("active"), + getCurrentThermalState: Effect.succeed("nominal"), + onSimpleEvent: () => Effect.void, + onThermalStateChange: () => Effect.void, + onSpeedLimitChange: () => Effect.void, + }), + ); + const layer = DesktopTelemetryPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerElectronApp([]), + layerPower, + layerHistory, + Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { + installIntoProcess: Effect.succeed(true), + awaitDiscovery: Effect.succeed(true), + updates: Stream.make({ + version: 1, + type: "shellEnvironment", + discovered: true, + platform: "darwin", + environment: { PATH: "/discovered/bin" }, + }), + }), + ), ), - getSystemIdleState: () => Effect.succeed("active"), - getCurrentThermalState: Effect.succeed("nominal"), - onSimpleEvent: () => Effect.void, - onThermalStateChange: () => Effect.void, - onSpeedLimitChange: () => Effect.void, - }), - ); - const layer = DesktopTelemetryPublisher.layer.pipe( - Layer.provide(Layer.mergeAll(layerElectronApp([]), layerPower, layerHistory)), - ); - const scope = yield* Scope.make(); + ); + const scope = yield* Scope.make(); - yield* Layer.buildWithScope(layer, scope); - yield* Deferred.await(pollStarted); + const context = yield* Layer.buildWithScope(layer, scope); + const publisher = Context.get(context, DesktopTelemetryPublisher.DesktopTelemetryPublisher); + const update = yield* publisher.encoded.pipe( + Stream.filter((bytes) => new TextDecoder().decode(bytes).includes("shellEnvironment")), + Stream.runHead, + ); + assert.isTrue(Option.isSome(update)); + if (Option.isSome(update)) + assert.include(new TextDecoder().decode(update.value), "/discovered/bin"); + yield* Deferred.await(pollStarted); - const closeFiber = yield* Scope.close(scope, Exit.void).pipe(Effect.forkDetach); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + const closeFiber = yield* Scope.close(scope, Exit.void).pipe(Effect.forkDetach); + yield* Effect.yieldNow; + yield* Effect.yieldNow; - assert.isDefined(closeFiber.pollUnsafe()); - }), + assert.isDefined(closeFiber.pollUnsafe()); + }), ); }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index fa5ebc80be081..d0e748971d7b5 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -24,6 +24,7 @@ import * as Stream from "effect/Stream"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; const LIVE_SAMPLE_INTERVAL = Duration.seconds(1); const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5); @@ -151,6 +152,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const electronApp = yield* ElectronApp.ElectronApp; const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; yield* electronApp.whenReady; const initialPowerState: PowerState = { @@ -406,7 +408,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { type: "desktopTelemetryHello", electronPid: process.pid, } as const), - Stream.merge(snapshots, updateReports), + Stream.merge(Stream.merge(snapshots, updateReports), shellEnvironment.updates), ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`))); const publishUpdateReport: DesktopTelemetryPublisher["Service"]["publishUpdateReport"] = ( diff --git a/apps/desktop/src/updates/DesktopUpdatePolicy.ts b/apps/desktop/src/updates/DesktopUpdatePolicy.ts new file mode 100644 index 0000000000000..1c68b7100cd3f --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdatePolicy.ts @@ -0,0 +1,89 @@ +/** + * DesktopUpdatePolicy - what the administrator's managed policy allows the desktop updater to do. + * The desktop reads the policy file itself, before any backend runs, and fails closed: a policy it + * cannot read turns updates off. + * + * @module DesktopUpdatePolicy + */ +import { + managedUpdatesMessage, + type ManagedPolicyDocument, + type ManagedUpdatesMode, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; + +export interface UpdatePolicy { + readonly mode: ManagedUpdatesMode; + readonly feedUrl: string | undefined; + readonly allowNightly: boolean; + /** Why the updater is restricted, shown in place of update actions; `null` when unmanaged. */ + readonly message: string | null; +} + +export class DesktopUpdatePolicy extends Context.Service()( + "@t3tools/desktop/updates/DesktopUpdatePolicy", +) {} + +export const unmanagedUpdatePolicy: UpdatePolicy = { + mode: "auto", + feedUrl: undefined, + allowNightly: true, + message: null, +}; + +function resolveUpdatePolicy(document: ManagedPolicyDocument | undefined): UpdatePolicy { + const updates = document?.limits?.updates; + if (document === undefined || updates === undefined) return unmanagedUpdatePolicy; + return { + mode: updates.mode, + feedUrl: updates.feedUrl, + allowNightly: updates.allowNightly ?? true, + message: managedUpdatesMessage(document.organization), + }; +} + +const { logInfo, logError } = DesktopObservability.makeComponentLogger("desktop-update-policy"); + +const make = Effect.gen(function* () { + return yield* (yield* DesktopManagedPolicy.DesktopManagedPolicy).read.pipe( + Effect.map((found) => ({ + source: found?.source, + policy: resolveUpdatePolicy(found?.document), + })), + Effect.tap(({ source, policy }) => + policy.message === null + ? Effect.void + : logInfo("managed policy restricts updates", { + source, + mode: policy.mode, + customFeed: policy.feedUrl !== undefined, + allowNightly: policy.allowNightly, + }), + ), + Effect.map(({ policy }) => policy), + Effect.catchTags({ + ManagedPolicyFileError: (error) => + logError("updates are off because the managed policy cannot be used", { + path: error.path, + reason: error.reason, + }).pipe( + Effect.as({ + mode: "off", + feedUrl: undefined, + allowNightly: false, + message: `Updates are off because Loom cannot use the managed policy at ${error.path}. Contact your administrator.`, // fork: brand + }), + ), + }), + ); +}); + +export const layer = Layer.effect(DesktopUpdatePolicy, make); + +export const layerTest = (policy: UpdatePolicy = unmanagedUpdatePolicy) => + Layer.succeed(DesktopUpdatePolicy, policy); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index a521b7bb1032b..492b7a0377470 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -18,6 +18,7 @@ import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; @@ -1015,3 +1016,115 @@ describe("describeUpdateErrorCause", () => { assert.equal(DesktopUpdates.describeUpdateErrorCause(unreadable), "UnknownError"); }); }); + +describe("DesktopUpdates under a managed policy", () => { + const message = "Example Corp manages T3 Code updates."; + const managed = ( + policy: Partial, + ): DesktopUpdatePolicy.UpdatePolicy => ({ + ...DesktopUpdatePolicy.unmanagedUpdatePolicy, + message, + ...policy, + }); + + it.effect("never checks when updates are off", () => { + const harness = makeHarness({ policy: managed({ mode: "off" }) }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* TestClock.adjust("10 minutes"); + + const state = yield* updates.getState; + assert.equal(state.status, "disabled"); + assert.equal(state.message, message); + assert.isFalse((yield* updates.check("menu")).checked); + assert.deepEqual(yield* updates.disabledReason, Option.some(message)); + assert.equal(harness.checkCount(), 0); + assert.equal(harness.listenerCount(), 0); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("updates from the administrator's feed", () => { + const harness = makeHarness({ + // Any user can set the mock flag; it must not redirect a managed feed. + env: { T3CODE_DESKTOP_MOCK_UPDATES: "true" }, + policy: managed({ feedUrl: "https://updates.example.com/t3code" }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + assert.equal((yield* updates.getState).status, "idle"); + assert.deepEqual(harness.feedUrls(), [ + { provider: "generic", url: "https://updates.example.com/t3code" }, + ]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("refuses the nightly channel without nightly", () => { + const harness = makeHarness({ policy: managed({ allowNightly: false }) }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const error = yield* updates.setChannel("nightly").pipe(Effect.flip); + assert.equal(error._tag, "DesktopUpdateChannelManagedError"); + assert.equal((yield* updates.getState).channel, "latest"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("does not report the channel as the only block when updates are off anyway", () => { + const harness = makeHarness({ + appVersion: "1.2.3-nightly.20261028.1", + env: { T3CODE_DISABLE_AUTO_UPDATE: "true" }, + policy: managed({ allowNightly: false, feedUrl: "https://updates.example.com/t3code" }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const state = yield* updates.getState; + assert.isFalse(state.enabled); + assert.isUndefined(state.channelBlockedByPolicy); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("holds a nightly install until its user switches to stable", () => { + const harness = makeHarness({ + appVersion: "1.2.3-nightly.20261028.1", + // A managed app ignores the mock feed, so give it a real one. + policy: managed({ allowNightly: false, feedUrl: "https://updates.example.com/t3code" }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* TestClock.adjust("10 minutes"); + + const held = yield* updates.getState; + assert.equal(held.channel, "nightly"); + assert.equal(held.status, "disabled"); + assert.include(held.message ?? "", message); + assert.isTrue(held.channelBlockedByPolicy); + assert.isFalse((yield* updates.check("menu")).checked); + assert.equal(harness.checkCount(), 0); + + const stable = yield* updates.setChannel("latest"); + assert.equal(stable.channel, "latest"); + assert.isTrue(stable.enabled); + assert.isUndefined(stable.channelBlockedByPolicy); + assert.equal(harness.checkCount(), 1); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); +}); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 16a34c652263b..e0c9f6e3250e3 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -34,6 +34,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import { makeSaveBeforeUpdate } from "../fork/rollback-snapshot/RollbackSnapshot.ts"; // fork: rollback-snapshot // fork: machine-updates import { makeDesktopIdleInstall } from "../fork/machine-updates/DesktopIdleInstall.ts"; @@ -174,9 +175,22 @@ export class DesktopUpdateUnexpectedActionError extends Schema.TaggedError()( + "DesktopUpdateChannelManagedError", + { + channel: DesktopUpdateChannelSchema, + policyMessage: Schema.String, + }, +) { + override get message(): string { + return `${this.policyMessage} The ${this.channel} channel is not available.`; + } +} + export const DesktopUpdateSetChannelError = Schema.Union([ DesktopUpdateActionInProgressError, DesktopUpdateChannelPersistenceError, + DesktopUpdateChannelManagedError, ]); export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; @@ -334,6 +348,7 @@ function shouldBroadcastDownloadProgress( } function getAutoUpdateDisabledReason(args: { + policy: DesktopUpdatePolicy.UpdatePolicy; isDevelopment: boolean; isPackaged: boolean; platform: NodeJS.Platform; @@ -342,6 +357,9 @@ function getAutoUpdateDisabledReason(args: { disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { + if (args.policy.mode === "off") { + return args.policy.message ?? "Updates are turned off on this machine."; + } if (!args.hasUpdateFeedConfig) { return "Automatic updates are not available because no update feed is configured."; } @@ -372,6 +390,9 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const saveRollbackSnapshot = yield* makeSaveBeforeUpdate; // fork: rollback-snapshot + const policy = yield* DesktopUpdatePolicy.DesktopUpdatePolicy; + // Any user can set the mock flag, so it never replaces an administrator's update source. + const mockUpdates = config.mockUpdates && policy.message === null; const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); const activeUpdateActionRef = yield* Ref.make>(Option.none()); @@ -458,13 +479,17 @@ export const make = Effect.gen(function* () { : false; const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( - Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates), + Effect.map( + (appUpdateYmlConfig) => + Option.isSome(appUpdateYmlConfig) || mockUpdates || policy.feedUrl !== undefined, + ), ); const resolveDisabledReason = Effect.gen(function* () { const hasFeedConfig = yield* hasUpdateFeedConfig; return Option.fromNullishOr( getAutoUpdateDisabledReason({ + policy, isDevelopment: environment.isDevelopment, isPackaged: environment.isPackaged, platform: environment.platform, @@ -476,6 +501,18 @@ export const make = Effect.gen(function* () { ); }); + // Without nightly, an install on that channel stays put until its user picks stable: moving it + // silently would be a downgrade nobody asked for, and checking nightly would break the policy. + const channelBlockedReason = (channel: DesktopUpdateChannel): Option.Option => + channel === "nightly" && !policy.allowNightly + ? Option.some( + `${policy.message ?? "Nightly updates are turned off on this machine."} Switch to the stable channel to keep getting updates.`, + ) + : Option.none(); + + const resolveStateDisabledReason = (channel: DesktopUpdateChannel) => + resolveDisabledReason.pipe(Effect.map(Option.orElse(() => channelBlockedReason(channel)))); + const activeUpdateAction = Ref.get(activeUpdateActionRef); const tryStartUpdateAction = (action: UpdateAction): Effect.Effect => @@ -527,6 +564,8 @@ export const make = Effect.gen(function* () { if (!(yield* Ref.get(updaterConfiguredRef))) return false; const state = yield* Ref.get(updateStateRef); + // A configured updater is disabled only by a channel the managed policy blocks. + if (state.status === "disabled") return false; if (state.status === "downloading") { yield* logUpdaterInfo("skipping update check while update is active", { reason, @@ -686,6 +725,11 @@ export const make = Effect.gen(function* () { Ref.get(notAppliedVersionRef).pipe( Effect.map((notAppliedVersion) => ({ ...createBaseUpdateState(channel, disabledReason, environment), + // Callers fall back to the channel's reason only when nothing else turns updates off. + ...(Option.isSome(disabledReason) && + disabledReason.value === Option.getOrUndefined(channelBlockedReason(channel)) + ? { channelBlockedByPolicy: true as const } + : {}), ...Option.match(notAppliedVersion, { onNone: () => ({}), onSome: (version) => ({ notAppliedVersion: version }), @@ -1106,7 +1150,9 @@ export const make = Effect.gen(function* () { }), ), emitState, - disabledReason: resolveDisabledReason, + disabledReason: Ref.get(updateStateRef).pipe( + Effect.flatMap((state) => resolveStateDisabledReason(state.channel)), + ), configure: Effect.gen(function* () { const context = yield* Effect.context(); const runEffect = (effect: Effect.Effect) => { @@ -1125,16 +1171,27 @@ export const make = Effect.gen(function* () { }); } - if (config.mockUpdates) { + if (mockUpdates) { yield* electronUpdater.setFeedURL({ provider: "generic", url: `http://localhost:${config.mockUpdateServerPort}`, } as ElectronUpdater.ElectronUpdaterFeedUrl); + } else if (policy.feedUrl !== undefined) { + // The administrator's mirror replaces the GitHub feed baked into app-update.yml. + yield* electronUpdater.setFeedURL({ + provider: "generic", + url: policy.feedUrl, + } as ElectronUpdater.ElectronUpdaterFeedUrl); } const settings = yield* desktopSettings.get; const disabledReason = yield* resolveDisabledReason; - yield* setState(yield* baseUpdateState(settings.updateChannel, disabledReason)); + yield* setState( + yield* baseUpdateState( + settings.updateChannel, + Option.orElse(disabledReason, () => channelBlockedReason(settings.updateChannel)), + ), + ); if (Option.isSome(disabledReason)) { return; } @@ -1185,6 +1242,12 @@ export const make = Effect.gen(function* () { options?: { readonly minimumVersion?: string | undefined }, ) { yield* Effect.annotateCurrentSpan({ channel: nextChannel }); + if (Option.isSome(channelBlockedReason(nextChannel))) { + return yield* new DesktopUpdateChannelManagedError({ + channel: nextChannel, + policyMessage: policy.message ?? "Your administrator manages Loom updates.", // fork: brand + }); + } // A background check holds the updater only briefly, so wait it out instead of refusing. const activeAction = yield* tryStartChannelChange.pipe( Effect.repeat({ diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 30db8274c208f..6f05b1e9e94ab 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -92,6 +92,26 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ]); }); + it.each([ + "**v1.2.4** · 3 changes from 2 contributors\n- **One-time tasks.** Pick a date.\n\n### Plus 2 more bug fixes and performance improvements.", + "

v1.2.4 · 3 changes from 2 contributors

  • One-time tasks. Pick a date.

Plus 2 more bug fixes and performance improvements.

", + ])("keeps highlight labels and the remaining change count from the feed", (summary) => { + const { releaseNotes } = normalizeDesktopUpdateReleaseNotes( + summary + "\n
Full changelograw list
", + "1.2.4", + "latest", + ); + expect(releaseNotes).toEqual([ + { + version: "1.2.4", + stats: "3 changes from 2 contributors", + items: ["**One-time tasks.** Pick a date."], + totalItems: 1, + footer: "Plus 2 more bug fixes and performance improvements.", + }, + ]); + }); + it("does not count Markdown or HTML section headings as changes", () => { const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); const result = normalizeDesktopUpdateReleaseNotes( diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 1fb81088175c1..a319898222092 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -54,17 +54,18 @@ function decodeHtmlEntities(input: string): string { ); } -function stripMarkup(input: string): string { - return decodeHtmlEntities( +function stripMarkup(input: string, keepBold = false): string { + const markdown = decodeHtmlEntities( input .replace(//gi, "\n") .replace(/]*>/gi, "\n- ") .replace(/]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `) .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") + .replace(/<\/?(?:strong|b)\b[^>]*>/gi, "**") .replace(/<[^>]*>/g, "") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1"), + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"), ); + return keepBold ? markdown : markdown.replace(/\*\*([^*]+)\*\*/g, "$1"); } function truncateReleaseNoteItem(item: string): string { @@ -92,40 +93,42 @@ function isIgnoredReleaseNoteLine(line: string): boolean { ); } -interface ExtractedReleaseNoteItems { - readonly items: ReadonlyArray; - readonly totalItems: number; - readonly intro?: string; - readonly stats?: string; -} +type ExtractedReleaseNoteItems = Omit; -/** - * Reads a generated summary (`scripts/generate-release-notes.ts`): a `v1.2.3 · N changes…` - * header, a highlights paragraph, and fix bullets. PR numbers are left to the GitHub page. - */ +/** Older generated notes have a highlights paragraph followed by a separate list of fixes. */ function extractReleaseNoteSummary(summary: string): ExtractedReleaseNoteItems { let stats: string | undefined; + let footer: string | undefined; const intro: string[] = []; const items: string[] = []; - for (const rawLine of stripMarkup(summary).split("\n")) { - const line = rawLine.trim().replace(/\s+/g, " "); - if (line === "" || /^#{1,6}\s+/.test(line)) continue; - if (stats === undefined && /^v\S+ · /.test(line)) { - stats = line.slice(line.indexOf(" · ") + 3); - continue; - } - const text = line - .replace(/^[-*]\s+/, "") + for (const rawLine of stripMarkup(summary, true).split("\n")) { + const line = rawLine + .trim() + .replace(/\s+/g, " ") .replace(/\s*\((?:#\d+(?:,\s*)?)+\)\.?$/, "") .trim(); - if (/^[-*]\s+/.test(line)) items.push(text); - else intro.push(text); + const plain = line.replace(/\*\*([^*]+)\*\*/g, "$1"); + if (/^### Plus \d+ more /.test(plain)) { + footer = plain.slice(4); + continue; + } + if (plain === "" || /^#{1,6}\s+/.test(plain)) continue; + if (stats === undefined && /^v\S+ · /.test(plain)) { + stats = plain.slice(plain.indexOf(" · ") + 3); + continue; + } + if (/^[-*]\s+/.test(line)) { + items.push(line.replace(/^[-*]\s+/, "")); + } else { + intro.push(plain); + } } return { items, totalItems: items.length + intro.length, ...(intro.length > 0 ? { intro: intro.join(" ") } : {}), ...(stats ? { stats } : {}), + ...(footer ? { footer } : {}), }; } @@ -184,16 +187,13 @@ export function normalizeDesktopUpdateReleaseNotes( ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { - const { items, totalItems, intro, stats } = extractReleaseNoteItems(entry.note); - if (totalItems === 0) return []; + const note = extractReleaseNoteItems(entry.note); + if (note.totalItems === 0 && !note.stats) return []; const releasedAt = resolveReleasedAt(entry.version, fallbackVersion, releaseDate); return [ { version: entry.version, - items, - totalItems, - ...(intro ? { intro } : {}), - ...(stats ? { stats } : {}), + ...note, ...(releasedAt ? { releasedAt } : {}), }, ]; diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index d8d16b7dbfd62..ec3b1ce9a1d59 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -13,6 +13,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; export const flushCallbacks = Effect.yieldNow; @@ -35,6 +36,7 @@ export interface UpdatesHarnessOptions { readonly platform?: NodeJS.Platform; readonly packageType?: string | undefined; readonly installRecord?: string | undefined; + readonly policy?: DesktopUpdatePolicy.UpdatePolicy; readonly appVersion?: string; /** Starts from in-memory settings with this track saved, as after a restart. */ readonly savedChannel?: DesktopAppSettings.DesktopSettings["updateChannel"]; @@ -271,6 +273,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const layer = DesktopUpdates.layer.pipe( Layer.provide(layerFileSystem), + Layer.provide(DesktopUpdatePolicy.layerTest(options.policy)), Layer.provideMerge(layerUpdater), Layer.provideMerge(layerWindow), Layer.provideMerge(layerBackend), diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 67549f6b8a54f..69962f52e4f18 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -1,10 +1,18 @@ // @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell. -import { describe, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; +import { ManagedPolicyFileError } from "@t3tools/shared/managedPolicyFile"; + +import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import { buildWslRuntimeInstallScript, + makeIsAvailable, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, buildWslRuntimeProbeScript, @@ -425,3 +433,29 @@ describe("parseResolvedPath", () => { ); }); }); + +// Path.layer joins with POSIX separators here, so the test windir is a POSIX path. +const WSL_EXE = "/windows/System32/wsl.exe"; + +const isAvailable = (policy?: Parameters[0]) => + makeIsAvailable("win32", "/windows").pipe( + Effect.provide( + Layer.mergeAll( + FileSystem.layerNoop({ exists: (path) => Effect.succeed(path === WSL_EXE) }), + Path.layer, + DesktopManagedPolicy.layerTest(policy), + ), + ), + ); + +describe("makeIsAvailable", () => { + it.effect("turns WSL off on a machine with a managed policy, even an invalid one", () => + Effect.gen(function* () { + assert.isTrue(yield* isAvailable()); + assert.isFalse(yield* isAvailable({ schemaVersion: 1, organization: "Example Corp" })); + assert.isFalse( + yield* isAvailable(new ManagedPolicyFileError({ path: "/test", reason: "bad JSON" })), + ); + }), + ); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 1ec88cfb96825..596675a2c1863 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -13,6 +13,7 @@ import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; const PROCESS_TERMINATE_GRACE = Duration.seconds(1); @@ -1028,15 +1029,26 @@ const getUserHomeImpl = ( Effect.orElseSucceed(() => Option.none()), ); -const makeIsAvailable = Effect.fn("DesktopWslEnvironment.makeIsAvailable")(function* ( +export const makeIsAvailable = Effect.fn("DesktopWslEnvironment.makeIsAvailable")(function* ( platform: NodeJS.Platform, windir: string, -): Effect.fn.Return { +): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | DesktopManagedPolicy.DesktopManagedPolicy +> { if (platform !== "win32") return false; const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; const wslExePath = path.join(windir, "System32", "wsl.exe"); - return yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)); + if (!(yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)))) { + return false; + } + // A server in WSL reads Linux's policy, never this machine's, so a managed machine runs none. + return yield* (yield* DesktopManagedPolicy.DesktopManagedPolicy).read.pipe( + Effect.map((found) => found === undefined), + Effect.orElseSucceed(() => false), + ); }); export interface DesktopWslEnvironmentTestStub { @@ -1141,6 +1153,10 @@ export const layer = Layer.effect( const wslAvailable = yield* makeIsAvailable(environment.platform, windir).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, environment.path), + Effect.provideService( + DesktopManagedPolicy.DesktopManagedPolicy, + yield* DesktopManagedPolicy.DesktopManagedPolicy, + ), Effect.withSpan("desktop.wsl.isAvailable"), ); const isAvailable = Effect.succeed(wslAvailable); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 55c3aa03b6490..1160d9011275d 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -93,7 +93,7 @@ export default defineConfig({ dts: false, sourcemap: true, outExtensions: () => ({ js: ".cjs" }), - entry: ["src/boot.ts", "src/compileCache.ts"], + entry: ["src/boot.ts", "src/compileCache.ts", "src/serverCompileCache.ts"], clean: false, deps: { neverBundle: (id) => id === "./main.cjs" || id === "./compileCache.cjs", diff --git a/apps/mobile/src/connection/background-activity.ts b/apps/mobile/src/connection/background-activity.ts index 8dec20d284f08..66eb2b10e0263 100644 --- a/apps/mobile/src/connection/background-activity.ts +++ b/apps/mobile/src/connection/background-activity.ts @@ -24,6 +24,8 @@ import { } from "./background-activity-scopes"; const REPORT_INTERVAL_MS = 25_000; +// Shorter than the interval, so a report that never settles cannot hold up the next one. +const REPORT_TIMEOUT_MS = 15_000; const LEASE_TTL_MS = 45_000; const BASELINE_SCOPES: ReadonlyArray = [{ type: "provider-status" }]; @@ -81,7 +83,8 @@ export const layerReporter = Layer.effectDiscard( ) // Not `ignore`: tearing down an environment mid-request interrupts its report, // and an interrupt leaking out here would end the reporting loop for good. - .pipe(Effect.ignoreCause), + // The timeout covers a live connection that never answers, which would stall it. + .pipe(Effect.timeout(`${REPORT_TIMEOUT_MS} millis`), Effect.ignoreCause), { concurrency: "unbounded", discard: true }, ); }).pipe(Effect.withSpan("mobile.backgroundActivity.report")); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 83a56827319fa..758e649dd3228 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -36,7 +36,7 @@ import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-gro import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { use, useEffect, useRef, useState, type ComponentProps } from "react"; +import { use, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import { Platform, ScrollView, @@ -415,11 +415,16 @@ export function HomeScreen(props: HomeScreenProps) { onScrollBeginDrag: handleScrollBeginDrag, }); - const projectScopes = buildHomeProjectScopes({ - projects: props.projects, - environmentId: props.selectedEnvironmentId, - projectGroupingMode: props.projectGroupingMode, - }); + const { projects, selectedEnvironmentId, projectGroupingMode } = props; + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode, + }), + [projects, selectedEnvironmentId, projectGroupingMode], + ); // fork: library - A Library scope lists the local projects its saved threads came from. const sharedAuthority = useLibraryAuthority(); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 993b13a8f9ec8..cf14533d29dec 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -37,6 +37,7 @@ import { getNewProjectPublishTarget, getNewProjectPublishTargets, getNewProjectRepository, + githubIncompleteOwnerRepositoryMessage, defaultHostCloneUrl, normalizePastedCloneUrl, parseAddProjectRemoteSource, @@ -939,6 +940,11 @@ export function AddProjectRepositoryScreen(props: { const lookupRepository = async () => { if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; + const incompleteOwnerMessage = githubIncompleteOwnerRepositoryMessage(source, repositoryInput); + if (incompleteOwnerMessage) { + setError(incompleteOwnerMessage); + return; + } setError(null); setIsSubmitting(true); const provider = addProjectRemoteSourceProvider(source); diff --git a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx index 8f4b1e45a869c..e9f288ea9c60c 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx @@ -14,6 +14,7 @@ import { PullRequestAvatar } from "./PullRequestPresentation"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestCommentFooter } from "./PullRequestReactions"; import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; +import { discussionCommentCountLabel } from "./pullRequestDiffComments"; import { normalizedReviewState } from "./pullRequestStatus"; import type { PullRequestParams } from "./usePullRequestDiff"; @@ -250,7 +251,7 @@ export function pullRequestTimelineRows( {thread.isResolved ? "Resolved" : "Unresolved"} {thread.isOutdated ? " · Outdated" : ""} ·{" "} - {thread.commentCount ?? thread.comments.length} comments + {discussionCommentCountLabel(thread.commentCount ?? thread.comments.length)} diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffComments.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffComments.test.ts index 2573436f3acba..d468e352c04c3 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffComments.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffComments.test.ts @@ -96,6 +96,22 @@ describe("PR comments in continuous native diffs", () => { ]); expect(annotated.commentTargetsByRowId).toBe(data.commentTargetsByRowId); }); + it("counts a single-comment discussion in the singular", () => { + const annotated = annotatePullRequestDiff( + data, + [ + { ...thread, commentCount: 1 }, + { ...thread, id: "right", side: "right", commentCount: 2 }, + ], + [], + "abc", + ); + expect( + annotated.rows + .filter((row) => row.kind === "comment") + .map((row) => row.commentText?.split("\n").at(-1)), + ).toEqual(["1 comment · Tap to open", "2 comments · Tap to open"]); + }); it("uses the old line number for left-side context when the line numbers differ", () => { const position = { kind: "context" as const, side: "left" as const, oldLine: 7, newLine: 8 }; const annotated = annotatePullRequestDiff( diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffComments.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffComments.ts index 097ede3867e5d..6da3f8e0272de 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffComments.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffComments.ts @@ -18,6 +18,9 @@ export function reviewPosition(row: NativeReviewDiffRow): PullRequestReviewPosit }; return null; } +export function discussionCommentCountLabel(count: number) { + return `${count} ${count === 1 ? "comment" : "comments"}`; +} export function reviewPositionLabel(position: PullRequestReviewPosition) { const oldSide = position.kind === "deleted" || (position.kind === "context" && position.side === "left"); @@ -79,7 +82,7 @@ export function annotatePullRequestDiff( attach( anchor, `discussion:${thread.id}`, - `${first?.author?.login ?? "Discussion"}${thread.isResolved ? " · Resolved" : ""}\n${first?.body ?? ""}\n${thread.commentCount ?? thread.comments.length} comments · Tap to open`, + `${first?.author?.login ?? "Discussion"}${thread.isResolved ? " · Resolved" : ""}\n${first?.body ?? ""}\n${discussionCommentCountLabel(thread.commentCount ?? thread.comments.length)} · Tap to open`, thread.line === null ? "file" : String(thread.line), ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index e1873d28da145..222cfdccaeeae 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -8,6 +8,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" import { AuthProvidersManageScope, AuthOrchestrationReadScope, + managedUpdatesDenial, type EnvironmentId, type ServerProvider, } from "@t3tools/contracts"; @@ -133,6 +134,10 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm upstreamOwnerUrl?.searchParams.set("machine", environmentId); if (upstreamOwnerUrl) upstreamOwnerUrl.hash = "loom-upstream-supervisor"; const osManaged = runtimeManagedByOperatingSystem(capabilities); // fork: runtime-owner + const managedPolicy = config?.managedPolicy; + const supportUrl = managedPolicy?.supportUrl; + // The server refuses checks and installs; say so instead of offering them. + const updatesDenial = managedUpdatesDenial(managedPolicy); async function run(label: string, action: () => Promise) { if (pendingRef.current) return; @@ -346,6 +351,14 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm Version {version} + {managedPolicy ? ( + + Managed by {managedPolicy.organization} + + ) : null} + {updatesDenial ? ( + {updatesDenial} + ) : null} {running ? ( {updateState.stage === "resuming" @@ -357,7 +370,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm {updateState.message} ) : null} - {checkedRelease ? ( + {checkedRelease && !updatesDenial ? ( {checkedRelease.targetVersion ? `Version ${checkedRelease.targetVersion} is available.` @@ -369,6 +382,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm {OS_MANAGED_RUNTIME_NOTE} // fork: runtime-owner ) : checkedRelease?.targetVersion && + !updatesDenial && !supportsEnvironmentUpdate(config.environment.capabilities) ? ( // fork: runtime-owner {capabilities?.serverSelfUpdate === "desktop-managed" @@ -378,22 +392,32 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm ) : null} - { - if (disabled) return; - const fromVersion = config.environment.serverVersion; - void run("check", async () => { - const exit = await checkRelease(async () => fromVersion); - // `run` owns the error message, so a failed lookup rethrows to it. - if (Exit.isFailure(exit)) throw Cause.squash(exit.cause); - }); - }} - /> + {supportUrl ? ( + void Linking.openURL(supportUrl).catch(() => undefined)} + /> + ) : null} + {updatesDenial ? null : ( + { + if (disabled) return; + const fromVersion = config.environment.serverVersion; + void run("check", async () => { + const exit = await checkRelease(async () => fromVersion); + // `run` owns the error message, so a failed lookup rethrows to it. + if (Exit.isFailure(exit)) throw Cause.squash(exit.cause); + }); + }} + /> + )} {checkedRelease?.targetVersion && + !updatesDenial && supportsEnvironmentUpdate(config.environment.capabilities) ? ( runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ - canConfigureEnvironment: (environmentId) => - readEnvironmentScope(environmentId, AuthRelayWriteScope), + canConfigureEnvironment: canLinkEnvironmentToConnect, enabled: true, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, @@ -362,8 +381,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ - canConfigureEnvironment: (environmentId) => - readEnvironmentScope(environmentId, AuthRelayWriteScope), + canConfigureEnvironment: canLinkEnvironmentToConnect, enabled: false, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, diff --git a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx index 3154e496f8073..61f46460f5674 100644 --- a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx @@ -7,6 +7,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AuthProvidersManageScope, + isManagedDriverAllowed, type ProviderAuthResponse, type ServerProvider, } from "@t3tools/contracts"; @@ -67,7 +68,7 @@ export function SettingsProviderAccountsRouteScreen() { ) : ( <> - {(providersByEnvironment.get(environment.environmentId) ?? []) + {managedProviders(environment, providersByEnvironment) .filter(canManageProvider) .map((provider, index) => ( 0} /> ))} - {!(providersByEnvironment.get(environment.environmentId) ?? []).some( + {!managedProviders(environment, providersByEnvironment).some( canManageProvider, ) ? ( @@ -102,6 +103,16 @@ export function SettingsProviderAccountsRouteScreen() { ); } +/** Providers the environment's managed policy allows; the rest cannot run or sign in. */ +function managedProviders( + environment: SettingsTarget, + providersByEnvironment: ReadonlyMap>, +) { + return (providersByEnvironment.get(environment.environmentId) ?? []).filter((provider) => + isManagedDriverAllowed(environment.serverConfig.managedPolicy, provider.driver), + ); +} + function canManageProvider(provider: ServerProvider) { return Boolean( provider.setup?.canAuthenticate || (provider.driver === "acpRegistry" && provider.installed), diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 2419a336981e6..5b8bee5426a47 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -6,6 +6,8 @@ import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, + clampManagedRuntimeMode, + managedCapabilityDenial, resolveProjectProviderInstanceEnabled, } from "@t3tools/contracts"; import { readEnvironmentScope } from "../../state/session"; @@ -699,6 +701,8 @@ function TaskForm({ .sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null)); const config = useEnvironmentServerConfig(environmentId); const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); + // Existing webhook tasks keep the option so they can still switch to a timer. + const webhookDenial = managedCapabilityDenial(config?.managedPolicy, "webhookTasks"); const canOperate = useAtomValue( serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), ); @@ -809,7 +813,9 @@ function TaskForm({ baseRef: draft.baseRef.trim() || "main", startFromOrigin: draft.startFromOrigin, }, - runtimeMode: draft.runtimeMode, + // Tasks have no mode picker; a mode above the administrator's ceiling is refused. + runtimeMode: + clampManagedRuntimeMode(config?.managedPolicy, draft.runtimeMode) ?? draft.runtimeMode, interactionMode: draft.task?.interactionMode ?? "default", creationSource: draft.task?.creationSource ?? "mobile", }; @@ -1008,7 +1014,9 @@ function TaskForm({ ? [{ value: "once" as const, label: "Once" }] : []), { value: "interval", label: "Interval" }, - { value: "webhook", label: "On webhook" }, + ...(webhookDenial === undefined || draft.schedule.mode === "webhook" + ? [{ value: "webhook" as const, label: "On webhook" }] + : []), ]} selected={draft.schedule.mode} onSelect={(mode) => { @@ -1109,6 +1117,10 @@ function TaskForm({ }} /> + ) : draft.schedule.mode === "webhook" && webhookDenial !== undefined && draft.enabled ? ( + + {`${webhookDenial} Switch the task to a timer, or turn it off, to save it.`} + ) : draft.schedule.mode === "once" ? ( (["date", "time"] as const).map((field) => { const runAt = new Date(draft.schedule.runAt); @@ -1272,7 +1284,14 @@ function TaskForm({ tone="primary" fullWidth loading={saving} - disabled={!canOperate || dictationPending || taskMissing || environmentUnavailable} + disabled={ + !canOperate || + dictationPending || + taskMissing || + environmentUnavailable || + // The server refuses an enabled webhook task under this policy. + (draft.enabled && draft.schedule.mode === "webhook" && webhookDenial !== undefined) + } onPress={() => void save()} label={saving ? "Saving…" : draft.task ? "Save changes" : "Create task"} /> @@ -1477,6 +1496,11 @@ function EnvironmentTasks({ ? ` · ${formatNextScheduledTaskRun(task.nextRunAt, now)}` : ""} + {task.blockedByPolicy ? ( + + {task.blockedByPolicy} + + ) : null} {/* fork: vault-scheduled-outcomes */} {task.lastRunError ? ( diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index b7e580846b361..bf945dde12472 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -97,7 +97,10 @@ vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environme vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.updateSettings, })); -vi.mock("../threads/thread-settings-options", () => ({ RUNTIME_MODE_CHOICES: [] })); +vi.mock("../threads/thread-settings-options", () => ({ + RUNTIME_MODE_CHOICES: [], + withManagedRuntimeModeLimits: (choices: readonly unknown[]) => choices, +})); vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children })); vi.mock("../../components/ui/AppText", () => ({ AppText: Children })); vi.mock("../../components/ui/Button", () => ({ diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 510cfe5a00938..959d32a672411 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -19,7 +19,10 @@ import { useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { RUNTIME_MODE_CHOICES } from "../threads/thread-settings-options"; +import { + RUNTIME_MODE_CHOICES, + withManagedRuntimeModeLimits, +} from "../threads/thread-settings-options"; import { SettingsScreen } from "./components/SettingsScreen"; import { AndroidSettingsEnvironmentFilter, @@ -35,6 +38,7 @@ import { SettingsProjectOverridesSection } from "./components/SettingsProjectOve import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { useSettingsWriter } from "./useSettingsWriter"; import { + managedSettingReason, planMobileScopedSettingsClear, planMobileScopedSettingsPatch, resolveMobileSettingsTargets, @@ -262,8 +266,19 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { }); }; const disabled = !canWriteSettings || pendingWrites > 0 || !hasConnectedSelection; + const managed = (...keys: string[]) => managedSettingReason(targets, ...keys); + // A locked row keeps its explanation and says who manages it. + const managedSubtitle = (key: string, subtitle: string) => { + const reason = managed(key); + return reason === undefined ? subtitle : `${subtitle} ${reason}`; + }; + const runtimeModeChoices = withManagedRuntimeModeLimits( + RUNTIME_MODE_CHOICES, + targets.map((target) => target.environment.serverConfig.managedPolicy), + ); const disabledFor = (key: string) => disabled || + managed(key) !== undefined || (projectSelected && !PROJECT_SCOPED_SERVER_SETTING_KEYS.includes( key as (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number], @@ -288,6 +303,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : ( <> + {projectSelected ? ( @@ -328,26 +345,30 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > - {RUNTIME_MODE_CHOICES.map((choice, index) => ( + {runtimeModeChoices.map((choice, index) => ( 0} - disabled={disabledFor("defaultRuntimeMode")} + disabled={ + disabledFor("defaultRuntimeMode") || choice.disabledReason !== undefined + } onPress={() => write({ defaultRuntimeMode: choice.mode })} /> ))} @@ -370,7 +391,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { write({ enableAgentBrowserAccess: value })} @@ -402,7 +426,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { write({ removeAgentCreditsOnMerge: value })} @@ -412,7 +439,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { write({ defaultAutoPull: value })} @@ -427,7 +457,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { write({ newWorktreesStartFromOrigin: value })} @@ -435,6 +468,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { @@ -463,7 +497,11 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { mode={uniform("branchNamingMode")} prefix={uniform("branchNamePrefix")} instructions={uniform("branchNameInstructions")} - disabled={disabledFor("branchNamingMode")} + disabled={{ + mode: disabledFor("branchNamingMode"), + prefix: disabledFor("branchNamePrefix"), + instructions: disabledFor("branchNameInstructions"), + }} onChange={write} /> @@ -501,11 +539,12 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { write({ enableProviderUpdateChecks: value })} @@ -514,7 +553,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { @@ -547,3 +589,23 @@ function MixedValuesLabel(props: { readonly projectSelected: boolean }) { ); } + +/** Locked settings are refused when saved; say so before the user tries. */ +function ManagedPolicyNotice(props: { readonly targets: readonly ScopedMobileSettingsTarget[] }) { + const organizations = [ + ...new Set( + props.targets.flatMap((target) => { + const policy = target.environment.serverConfig.managedPolicy; + return policy !== undefined && policy.lockedSettings.length > 0 + ? [policy.organization] + : []; + }), + ), + ]; + if (organizations.length === 0) return null; + return ( + + Some settings are managed by {organizations.join(", ")} and cannot be changed here. + + ); +} diff --git a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx index 32d721e02b134..38c8c2ecfcff4 100644 --- a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx +++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx @@ -1,5 +1,9 @@ import { Button } from "../../components/ui/Button"; -import { AuthSettingsWriteScope, type ServerSettingsPatch } from "@t3tools/contracts"; +import { + AuthSettingsWriteScope, + isManagedSettingPath, + type ServerSettingsPatch, +} from "@t3tools/contracts"; import { useEnvironmentsWithScope } from "../../state/session"; import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; @@ -20,7 +24,11 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; -import { resolveMobileSettingsTargets, uniformMobileSetting } from "./settings-scoped-server"; +import { + managedSettingReason, + resolveMobileSettingsTargets, + uniformMobileSetting, +} from "./settings-scoped-server"; export function SettingsLabsRouteScreen() { const insets = useSafeAreaInsets(); @@ -98,6 +106,8 @@ export function AutoSettleSettingsRows(props: { ); const disabled = !canWriteSettings || props.pending; + const disabledFor = (...keys: string[]) => + disabled || managedSettingReason(displayTargets, ...keys) !== undefined; const afterDays = referenceSettings.sidebarAutoSettleAfterDays; return ( @@ -109,7 +119,7 @@ export function AutoSettleSettingsRows(props: { label="Resume after usage limits" subtitle="Automatically resume threads when their usage limits reset. You can cancel auto-resume in each thread." value={uniformMobileSetting(displayTargets, "autoResumeLimitedThreads")} - disabled={disabled} + disabled={disabledFor("autoResumeLimitedThreads")} onValueChange={(value) => writeToAll({ autoResumeLimitedThreads: value })} /> writeToAll({ snoozeLimitedThreads: value })} /> @@ -127,14 +137,14 @@ export function AutoSettleSettingsRows(props: { icon="arrow.triangle.branch" label="Auto-settle merged threads" value={referenceSettings.sidebarAutoSettleOnMerge} - disabled={disabled} + disabled={disabledFor("sidebarAutoSettleOnMerge")} onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} /> writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) } @@ -145,7 +155,7 @@ export function AutoSettleSettingsRows(props: { Inactive days writeToAll({ sidebarAutoSettleAfterDays: value })} /> @@ -162,7 +172,7 @@ export function AutoSettleSettingsRows(props: { + + ) : ( + {updatesDenial} + ); + } + if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { return ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8ff9fdd81c379..6e1a9bc87bd8b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,12 +2,11 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models"; import { isThreadWorking as isSidebarThreadWorking } from "@t3tools/client-runtime/state/thread-inbox"; import * as DateTime from "effect/DateTime"; import { deriveActiveWorkStartedAt } from "../session-logic.ts"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { shouldCreateNewThreadInCurrentProject, - archiveSelectedThreadEntries, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, hasUnseenCompletion, @@ -16,7 +15,6 @@ import { isSidebarEnvironmentDisconnected, orderItemsByPreferredIds, pinOrderKeyBetween, - resolveProjectStatusIndicator, resolveSidebarSweepKeys, resolveSidebarThreadStatus, resolveThreadStatusPill, @@ -28,7 +26,6 @@ import { planSidebarThreadDrop, resolveSidebarReorderableThreadKeys, sortPinnedThreadsForSidebar, - sortProjectsForSidebar, sortScopedProjectsForSidebar, sortSidebarV2ProjectGroups, shouldNavigateAfterThreadPark, @@ -140,43 +137,6 @@ describe("deleteSelectedThreadEntries", () => { }); }); -describe("archiveSelectedThreadEntries", () => { - const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; - const success = { _tag: "Success" } as const; - const failure = { _tag: "Failure" } as const; - - it("stops at a mutation failure and retains prior successes", async () => { - const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { - if (entry.threadKey === "two") return failure; - onArchived(); - return success; - }); - const outcome = await archiveSelectedThreadEntries({ entries, archive }); - - expect(archive).toHaveBeenCalledTimes(2); - expect(outcome).toEqual({ - archivedThreadKeys: ["one"], - mutationFailure: failure, - followupFailures: [], - }); - }); - - it("continues after a post-archive failure", async () => { - const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { - onArchived(); - return entry.threadKey === "two" ? failure : success; - }); - const outcome = await archiveSelectedThreadEntries({ entries, archive }); - - expect(archive).toHaveBeenCalledTimes(3); - expect(outcome).toEqual({ - archivedThreadKeys: ["one", "two", "three"], - mutationFailure: null, - followupFailures: [failure], - }); - }); -}); - describe("sidebar thread lineage helpers", () => { it("maps an open subagent to its nearest visible ancestor's sidebar row", () => { const environmentId = EnvironmentId.make("environment-row"); @@ -589,40 +549,6 @@ describe("resolveThreadStatusPill", () => { }); }); -describe("resolveProjectStatusIndicator", () => { - it("ranks waiting below active work and above plan-ready", () => { - const waiting = { - label: "Waiting" as const, - colorClass: "text-sidebar-muted-foreground", - dotClass: "bg-sidebar-muted-foreground", - pulse: false, - }; - - expect( - resolveProjectStatusIndicator([ - waiting, - { - label: "Working", - colorClass: "text-sky-600", - dotClass: "bg-sky-500", - pulse: true, - }, - ]), - ).toMatchObject({ label: "Working" }); - expect( - resolveProjectStatusIndicator([ - { - label: "Plan Ready", - colorClass: "text-violet-600", - dotClass: "bg-violet-500", - pulse: false, - }, - waiting, - ]), - ).toMatchObject({ label: "Waiting" }); - }); -}); - function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { @@ -703,54 +629,6 @@ describe("getFallbackThreadIdAfterDelete", () => { expect(fallbackThreadId).toBe(ThreadId.make("thread-next")); }); }); -describe("sortProjectsForSidebar", () => { - it("sorts projects by the most recent user message across their threads", () => { - const projects = [ - makeProject({ id: ProjectId.make("project-1"), title: "Older project" }), - makeProject({ id: ProjectId.make("project-2"), title: "Newer project" }), - ]; - const threads = [ - makeThread({ - projectId: ProjectId.make("project-1"), - updatedAt: "2026-03-09T10:20:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "user", - text: "older project user message", - runId: null, - createdAt: "2026-03-09T10:01:00.000Z", - updatedAt: "2026-03-09T10:01:00.000Z", - streaming: false, - }, - ], - }), - makeThread({ - id: ThreadId.make("thread-2"), - projectId: ProjectId.make("project-2"), - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [ - { - id: "message-2" as never, - role: "user", - text: "newer project user message", - runId: null, - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - streaming: false, - }, - ], - }), - ]; - - const sorted = sortProjectsForSidebar(projects, threads, "updated_at"); - - expect(sorted.map((project) => project.id)).toEqual([ - ProjectId.make("project-2"), - ProjectId.make("project-1"), - ]); - }); -}); describe("sortScopedProjectsForSidebar", () => { it("keeps identical project ids in different environments separate", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f29e874988bd8..45d976e208d46 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -588,38 +588,6 @@ export async function deleteSelectedThreadEntries< return { deletedThreadKeys, firstFailure }; } -export async function archiveSelectedThreadEntries< - TEntry extends { readonly threadKey: string }, - TResult extends { readonly _tag: "Success" | "Failure" }, ->(input: { - entries: readonly TEntry[]; - archive: (entry: TEntry, onArchived: () => void) => Promise; -}): Promise<{ - archivedThreadKeys: readonly string[]; - mutationFailure: Extract | null; - followupFailures: readonly Extract[]; -}> { - const archivedThreadKeys: string[] = []; - const followupFailures: Extract[] = []; - - for (const entry of input.entries) { - let didArchive = false; - const result = await input.archive(entry, () => { - didArchive = true; - }); - if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey); - if (result._tag === "Success") continue; - const failure = result as Extract; - if (didArchive) { - followupFailures.push(failure); - continue; - } - return { archivedThreadKeys, mutationFailure: failure, followupFailures }; - } - - return { archivedThreadKeys, mutationFailure: null, followupFailures }; -} - export function isSidebarHiddenThread(thread: Pick): boolean { return !isListedThreadLineage(thread.lineage); } @@ -714,20 +682,6 @@ export interface ThreadStatusPill { pulse: boolean; } -const THREAD_STATUS_PRIORITY: Record = { - "Pending Approval": 5, - "Awaiting Input": 4, - "Guest Request": 3.75, - Failed: 3.5, - // fork: attention-status - Paused: 3.5, - Working: 3, - Connecting: 3, - Waiting: 2.5, - "Plan Ready": 2, - Completed: 1, -}; - type ThreadStatusInput = Pick< SidebarThreadSummary, | "hasActionableProposedPlan" @@ -1279,24 +1233,6 @@ export function resolveThreadStatusPill(input: { return null; } -export function resolveProjectStatusIndicator( - statuses: ReadonlyArray, -): ThreadStatusPill | null { - let highestPriorityStatus: ThreadStatusPill | null = null; - - for (const status of statuses) { - if (status === null) continue; - if ( - highestPriorityStatus === null || - THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label] - ) { - highestPriorityStatus = status; - } - } - - return highestPriorityStatus; -} - export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { @@ -1365,29 +1301,6 @@ function sortProjectsByActivity( .map(({ project }) => project); } -export function sortProjectsForSidebar< - TProject extends SidebarProject, - TThread extends Pick & ThreadSortInput, ->( - projects: readonly TProject[], - threads: readonly TThread[], - sortOrder: SidebarProjectSortOrder, -): TProject[] { - const threadsByProjectId = new Map(); - for (const thread of threads) { - const existing = threadsByProjectId.get(thread.projectId) ?? []; - existing.push(thread); - threadsByProjectId.set(thread.projectId, existing); - } - - return sortProjectsByActivity( - projects, - sortOrder, - (project) => threadsByProjectId.get(project.id) ?? [], - (left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id), - ); -} - export function sortLogicalProjectsForSidebar< TProject extends LogicalSidebarProject, TThread extends ScopedSidebarThread, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 717cfc28688a6..f2f39238825f6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3208,10 +3208,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve )} + {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} closeType === "keyboard"} > setAccess(value as AuthMcpClientAccess)} > {AuthMcpClientAccess.literals.map((option) => ( - + ))}

@@ -276,17 +283,30 @@ function ConnectAgentHeading({ return ; } +/** Why a managed policy keeps `access` from being granted, or undefined when it may be. */ +function ceilingReason(details: AuthMcpApprovalDetails, access: AuthMcpClientAccess) { + const ceiling = details.runtimeModeCeiling; + return ceiling === undefined || + access === "read-only" || + !isRuntimeModeAbove(access, ceiling.maxRuntimeMode) + ? undefined + : managedRuntimeModeCeilingMessage(ceiling.organization, ceiling.maxRuntimeMode); +} + function AccessOption({ access, selected, + managedReason, }: { readonly access: AuthMcpClientAccess; readonly selected: boolean; + readonly managedReason: string | undefined; }) { const { label, description, icon: Icon } = accessConfig[access]; return ( {label} - {description} + {managedReason ?? description} ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a0a2be0ceeadf..f20d30c66542e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -39,6 +39,8 @@ import type { import { EnvironmentRequestInvalidError, AuthOrchestrationOperateScope, + clampManagedRuntimeMode, + managedRuntimeModeDenial, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -239,7 +241,9 @@ import { observeResize } from "~/lib/observeResize"; const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); import { toastManager } from "../ui/toast"; -import { PlayIcon } from "lucide-react"; +import { BuildingIcon, PlayIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { useManagedPolicy } from "../../state/managedPolicy"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; import { hasProviderSetup } from "./ProviderStatusBanner"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; @@ -919,9 +923,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) runtimeMode, selectedProviderEntry?.snapshot.supportedRuntimeModes, ); - const compatibleRuntimeModeOptions = runtimeModeOptions.filter((option) => - compatibleRuntimeModes.includes(option.mode), - ); + // Modes above an administrator's ceiling stay listed, disabled with the reason. + const managedPolicy = useManagedPolicy(environmentId); + const compatibleRuntimeModeOptions = runtimeModeOptions + .filter((option) => compatibleRuntimeModes.includes(option.mode)) + .map((option) => { + const disabledReason = managedRuntimeModeDenial(managedPolicy, option.mode); + return disabledReason === undefined ? option : { ...option, disabledReason }; + }); + // A thread or draft already above the ceiling would be refused; offer the broadest mode left. + const runtimeModeCeilingDenial = managedRuntimeModeDenial(managedPolicy, compatibleRuntimeMode); + const runtimeModeCeilingFallback = + runtimeModeCeilingDenial === undefined + ? undefined + : clampManagedRuntimeMode(managedPolicy, compatibleRuntimeMode, compatibleRuntimeModes); const providerSendBlockReason = getAntigravitySendBlockReason( selectedProviderEntry?.snapshot, @@ -933,6 +948,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (activePendingProgress ? attachmentBlockReason : (attachmentBlockReason ?? + runtimeModeCeilingDenial ?? (multipleModelSelections === null ? providerSendBlockReason : null))); const isSendDisabled = sendDisabledReason !== null; const selectedProviderStatus = selectedProviderEntry?.snapshot ?? null; @@ -2399,12 +2415,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); // An open question owns the space above the composer. Only a machine problem, // which is why an answer could not go through, stays beside it. + const bannerItems: readonly ComposerBannerStackItem[] = + runtimeModeCeilingDenial === undefined || activePendingProgress + ? props.bannerItems + : [ + ...props.bannerItems, + { + id: "managed-runtime-mode", + variant: "warning", + icon: , + title: runtimeModeCeilingDenial, + ...(runtimeModeCeilingFallback === undefined + ? {} + : { + actions: ( + + ), + }), + }, + ]; const shelfItems = hasBlockingComposerTopDrawer - ? props.bannerItems.filter( + ? bannerItems.filter( (item) => item.group === "environment" && (item.variant === "error" || item.variant === "warning"), ) - : props.bannerItems; + : bannerItems; const insertComposerText = ( text: string, diff --git a/apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx b/apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx new file mode 100644 index 0000000000000..ef136a1760137 --- /dev/null +++ b/apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx @@ -0,0 +1,52 @@ +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; + +const state = vi.hoisted(() => ({ respond: vi.fn(), approvalsAtom: null as unknown })); + +vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.respond })); +vi.mock("../../state/threads", () => ({ + childThreadApprovalsAtom: () => state.approvalsAtom, + threadEnvironment: { respondToApproval: {} }, +})); + +import { ChildThreadApprovals } from "./ChildThreadApprovals"; + +function setup() { + state.respond.mockReset(); + state.respond + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Child environment offline")))) + .mockResolvedValueOnce(AsyncResult.success(undefined)); + const thread = { + environmentId: EnvironmentId.make("child-env"), + threadId: ThreadId.make("child"), + }; + state.approvalsAtom = Atom.make>([ + { + thread, + title: "Compile task", + approval: { + requestId: RuntimeRequestId.make("approval"), + requestKind: "command", + detail: "Run compiler", + createdAt: "2026-11-01T00:00:00.000Z", + responseCapability: "live", + }, + }, + ]); + return thread; +} + +it("shows a failed decision and clears the error when the user retries", async () => { + const thread = setup(); + await using screen = await render(); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).toHaveTextContent("Child environment offline"); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ChildThreadApprovals.tsx b/apps/web/src/components/chat/ChildThreadApprovals.tsx new file mode 100644 index 0000000000000..5a7cb3074b4a6 --- /dev/null +++ b/apps/web/src/components/chat/ChildThreadApprovals.tsx @@ -0,0 +1,95 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { fullAccessOverrideExplanation } from "@t3tools/client-runtime/state/thread-requests"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; +import { useEnvironmentScope } from "../../state/session"; +import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; + +function ChildApproval({ entry }: { entry: ChildThreadApproval }) { + const canRespond = useEnvironmentScope(entry.thread.environmentId, AuthOrchestrationOperateScope); + const respond = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false }); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(null); + const { approval } = entry; + return ( +

+

Approval needed from {entry.title}

+
+        {approval.detail || approval.requestKind}
+      
+ {approval.appName ?

{approval.appName}

: null} + {approval.responseCapability !== "live" ? ( +

+ Provider process is gone. Interrupt or restart the child thread to respond. +

+ ) : null} + {approval.reason ?

{approval.reason}

: null} + {approval.fullAccessOverride ? ( +

+ {fullAccessOverrideExplanation(approval.fullAccessOverride)} +

+ ) : null} +
+ { + if (!canRespond || responding) return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> +
+ {error !== null ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export function ChildThreadApprovals({ threadRef }: { threadRef: ScopedThreadRef | null }) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + if (approvals.length === 0) return null; + return ( +
+ {approvals.map((entry) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/chat/ComposerFooterControls.tsx b/apps/web/src/components/chat/ComposerFooterControls.tsx index 31c5889db9b22..cb5e78c1e8cfe 100644 --- a/apps/web/src/components/chat/ComposerFooterControls.tsx +++ b/apps/web/src/components/chat/ComposerFooterControls.tsx @@ -21,8 +21,11 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; import { runtimeModeConfig } from "./runtimeModeConfig"; import { useComposerMenuState } from "./useComposerMenuState"; -export type RuntimeModeOption = { mode: RuntimeMode } & (typeof runtimeModeConfig)[RuntimeMode]; -const supervisedRuntimeModeOption = { +export type RuntimeModeOption = { + mode: RuntimeMode; + disabledReason?: string; +} & (typeof runtimeModeConfig)[RuntimeMode]; +const supervisedRuntimeModeOption: RuntimeModeOption = { mode: "approval-required" as const, ...runtimeModeConfig["approval-required"], }; @@ -161,9 +164,7 @@ export const ComposerFooterModeControls = memo(function ComposerFooterModeContro */ export function ComposerPermissionsSection(props: { runtimeMode: RuntimeMode; - runtimeModeOptions: ReadonlyArray< - { mode: RuntimeMode } & (typeof runtimeModeConfig)[RuntimeMode] - >; + runtimeModeOptions: ReadonlyArray; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { // Hover or keyboard focus previews a mode's name and description before a pick. @@ -203,6 +204,7 @@ export function ComposerPermissionsSection(props: { setPreviewMode(null)} onFocus={() => setPreviewMode(option.mode)} onBlur={() => setPreviewMode(null)} - className="flex h-6 flex-1 cursor-pointer items-center justify-center text-muted-foreground outline-none first:rounded-l-md last:rounded-r-md data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" + className="flex h-6 flex-1 cursor-pointer items-center justify-center text-muted-foreground outline-none first:rounded-l-md last:rounded-r-md data-disabled:cursor-not-allowed data-disabled:opacity-40 data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" > @@ -221,7 +223,7 @@ export function ComposerPermissionsSection(props: { {/* Static per-mode descriptions for screen readers; the preview below is visual only. */} {props.runtimeModeOptions.map((option) => ( - {option.description} + {option.disabledReason ?? option.description} ))} ); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 1c792cf4cec5a..c07b1f33c5fa9 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -13,7 +13,8 @@ import { function describeUnavailableInstance(entry: ProviderInstanceEntry): string { const label = entry.displayName; - if (!entry.enabled || entry.status === "disabled") { + // An unavailable instance (a driver this build lacks or the policy leaves out) says why instead. + if ((!entry.enabled && entry.isAvailable) || entry.status === "disabled") { return `${label}: Disabled in settings.`; } if (entry.status === "ready" && entry.isAvailable) { diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index ca2497bd20ee8..30eade3d813e8 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -41,7 +41,9 @@ function taskScheduleLine(task: ScheduledTask) { return ( <> {scheduleLabel(task.schedule)} - {task.enabled && task.nextRunAt !== null ? ( + {task.blockedByPolicy !== undefined ? ( + ` · ${task.blockedByPolicy}` + ) : task.enabled && task.nextRunAt !== null ? ( <> {" · next "} diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 4ae861d517d0f..8c3f814427991 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -438,7 +438,7 @@ export function ThreadDetailsPrRow({ {detail !== null && (showChecks || trailingAction !== null || watching) ? (
{openRow(detail.title, "row", true)} -
+
#{number} {showChecks && checksRollup !== null ? ( ) => { }; describe("chat canvas layout", () => { + it("does not enlarge the preview when its right edge is dragged inward", () => { + for (const height of [300, 400, 600, 900]) { + for (const x of [12, 200, 500, 700]) { + const start = resolve(1000, { ...preview, position: { x, y: 500 } }, height).frame!; + const resized = resizePreviewMiniPlayer({ + start, + direction: "east", + delta: { x: -30, y: 0 }, + source: preview.source, + container: { width: 1000, height }, + }); + expect(resized.width, JSON.stringify({ height, x, start, resized })).toBeLessThanOrEqual( + start.width, + ); + const rendered = resolve( + 1000, + { + ...preview, + width: resized.width, + position: resized, + lastInteraction: "resize", + }, + height, + ).frame!; + expect( + rendered.width, + JSON.stringify({ height, x, start, resized, rendered }), + ).toBeLessThanOrEqual(start.width); + expect(rendered.x).toBe(start.x); + expect(rendered.y, JSON.stringify({ height, x, start, resized, rendered })).toBe(start.y); + expect( + resolve(1000, { + ...preview, + width: resized.width, + position: resized, + lastInteraction: "resize", + }).frame!.width, + ).toBe(resized.width); + } + } + }); it("lifts a growing preview above the composer without snapping at the chat boundary", () => { let previous: ReturnType | undefined; for (let width = 480; width <= 1100; width++) { diff --git a/apps/web/src/components/chat/guestShareAvailability.ts b/apps/web/src/components/chat/guestShareAvailability.ts index e49d2e57e02ad..4e5c8a7791662 100644 --- a/apps/web/src/components/chat/guestShareAvailability.ts +++ b/apps/web/src/components/chat/guestShareAvailability.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, managedCapabilityDenial } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { environmentPresentations } from "~/state/presentation"; @@ -7,12 +7,15 @@ import { serverEnvironment } from "~/state/server"; /** * Whether this environment offers "Share with guest": the user may create links there, and its * Guest links beta is on (Settings > Labs). The server refuses links while the beta is off. + * Guests arrive through T3 Connect, so a policy that turns Connect off hides sharing too. */ export const canShareWithGuestAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make( - (get) => + Atom.make((get) => { + const serverConfig = get(environmentPresentations.serverConfigsAtom).get(environmentId); + return ( get(serverEnvironment.createGuestShare.permissionAtom(environmentId)) && - get(environmentPresentations.serverConfigsAtom).get(environmentId)?.settings - .guestSharingEnabled === true, - ).pipe(Atom.withLabel(`guest-share:available:${environmentId}`)), + serverConfig?.settings.guestSharingEnabled === true && + managedCapabilityDenial(serverConfig.managedPolicy, "connect") === undefined + ); + }).pipe(Atom.withLabel(`guest-share:available:${environmentId}`)), ); diff --git a/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx b/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx index a0b10dd4c643f..6c02319d25a63 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; @@ -20,7 +21,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({ vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ ...(await importOriginal()), - useOrchestrationCommand: (() => async () => ({ _tag: "Success", value: undefined })) as never, + useOrchestrationCommand: (() => (input: unknown) => state.switchRef(input)) as never, })); vi.mock(import("../../hooks/useThreadActions"), async (importOriginal) => ({ @@ -77,7 +78,7 @@ function RestoreBranchHarness(props: { serverConfig: null, runtimeMode: "full-access" as never, isWorking: false, - pendingBackgroundTasks: [], + pendingBackgroundTasks: [{ taskId: "background-command", kind: "command" }] as never, turnItems: [], activePendingApproval: null as never, activePendingUserInput: null as never, @@ -95,13 +96,16 @@ function RestoreBranchHarness(props: { persistThreadSettingsForNextTurn: async () => ({ _tag: "Success", value: undefined }) as never, }); return ( - + <> + {lifecycle.workNow?.actions} + + ); } @@ -181,3 +185,44 @@ it.each(["a", "b", "navigation"] as const)( expect(focusComposer).toHaveBeenCalledTimes(scenario === "navigation" ? 0 : 1); }, ); + +it.each(["Success", "Failure"] as const)( + "keeps background stop requests per thread after %s", + async (outcome) => { + setup(); + const completions: Array<(result: unknown) => void> = []; + state.switchRef = vi.fn(() => new Promise((resolve) => completions.push(resolve))); + const threadA = threadInput("thread:a", "feature-a"); + const threadB = threadInput("thread:b", "feature-b"); + const currentRouteThreadKeyRef = { current: threadA.routeThreadKey }; + const harness = (thread: ThreadInput) => ( + undefined} + focusComposer={() => undefined} + /> + ); + await using screen = await render(harness(threadA)); + await page.getByRole("button", { name: "Stop", exact: true }).click(); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + await screen.rerender(harness(threadB)); + await page.getByRole("button", { name: "Stop", exact: true }).click(); + await screen.rerender(harness(threadA)); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + completions[0]!( + outcome === "Success" + ? { _tag: "Success", value: undefined } + : { _tag: "Failure", cause: Cause.fail(new Error("stop failed")) }, + ); + if (outcome === "Success") { + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + } else { + await expect.element(page.getByRole("button", { name: "Stop", exact: true })).toBeEnabled(); + } + await screen.rerender(harness(threadB)); + await expect.element(page.getByRole("button", { name: "Stopping..." })).toBeDisabled(); + expect(state.switchRef).toHaveBeenCalledTimes(2); + completions[1]!({ _tag: "Success", value: undefined }); + }, +); diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 915f39a12a24e..6d71e3f59f05d 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -12,7 +12,7 @@ import { makeThreadErrorSetter, } from "../ChatView.logic"; import { useStableCallback } from "../../hooks/useStableCallback"; -import { useNowAfter } from "../../hooks/useDeadline"; +import { useDeadlinePassed, useNowAfter } from "../../hooks/useDeadline"; import type { usePendingThreadRequests } from "./usePendingThreadRequests"; import { AuthOrchestrationOperateScope, @@ -28,6 +28,11 @@ import { snoozeWakeLabel, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { + BACKGROUND_WORK_STOP_RETRY_AFTER_MS, + type BackgroundWorkStopRequest, + isBackgroundWorkStopPending, +} from "@t3tools/client-runtime/state/thread-execution"; import { useAcknowledgeThreadWoke, useThreadActions } from "../../hooks/useThreadActions"; import { settleAndAdvance } from "../../settleAndAdvance"; import type { ChatShortcutCommands } from "./useChatShortcuts"; @@ -400,21 +405,44 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { // The interrupt path also accepts a completed run while its provider still // has background work. const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : []; - const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); - const isStoppingBackgroundWork = - stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; + const [backgroundWorkStops, setBackgroundWorkStops] = useState< + Record + >({}); + const backgroundWorkStop = backgroundWorkStops[`${environmentId}:${activeThreadId}`] ?? null; + const isStoppingBackgroundWork = isBackgroundWorkStopPending( + backgroundWorkStop, + `${environmentId}:${activeThreadId}`, + pendingBackgroundTasks, + useDeadlinePassed(backgroundWorkStop?.retryAt ?? null), + ); const handleStopBackgroundWork = async () => { - if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + if ( + isStoppingBackgroundWork || + !activeThread || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) return; - const requestKey = `${environmentId}:${activeThread.id}`; - setStoppingBackgroundWorkKey(requestKey); + const request: BackgroundWorkStopRequest = { + threadKey: `${environmentId}:${activeThread.id}`, + taskIds: pendingBackgroundTasks.map((task) => task.taskId), + retryAt: null, + }; + setBackgroundWorkStops((current) => ({ ...current, [request.threadKey]: request })); const result = await interruptThreadTurn({ environmentId, input: { threadId: activeThread.id }, }); - // Acceptance does not confirm termination. Allow retry while the provider - // finishes stopping the tasks or reports a failure. - setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current)); + setBackgroundWorkStops((current) => + current[request.threadKey] !== request + ? current + : { + ...current, + [request.threadKey]: + result._tag === "Failure" + ? undefined + : { ...request, retryAt: Date.now() + BACKGROUND_WORK_STOP_RETRY_AFTER_MS }, + }, + ); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 2e6bb927d69dc..70c4f6e8579f2 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -1,6 +1,11 @@ import { useAuth } from "@clerk/react"; import { useAtomValue } from "@effect/atom-react"; -import { AuthRelayReadScope, AuthRelayWriteScope, type AuthSessionState } from "@t3tools/contracts"; +import { + AuthRelayReadScope, + AuthRelayWriteScope, + type AuthSessionState, + managedCapabilityDenial, +} from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -19,6 +24,7 @@ import { usePrimaryEnvironment, usePrimaryEnvironmentId, } from "~/state/environments"; +import { usePrimaryManagedPolicy } from "~/state/managedPolicy"; import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { type T3ConnectAccountPageUrl, @@ -64,7 +70,10 @@ function ConfiguredConnectOnboardingDialog() { ); const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope); const canWriteRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); - const canManageRelay = canReadRelay && canWriteRelay; + // A policy that turns Connect off leaves nothing to publish; reaching other devices still works. + const connectAllowed = + managedCapabilityDenial(usePrimaryManagedPolicy(), "connect") === undefined; + const canManageRelay = canReadRelay && canWriteRelay && connectAllowed; const sessionScopesKnown = primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial"; diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 7fb354b672c1a..04831bf6e494e 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -150,17 +150,21 @@ export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { ); } +/** `managedDenial` is this machine's policy refusing updates (`managedUpdatesDenial`); it wins. */ export function getDesktopUpdateDisabledReason( state: Pick | null, + managedDenial?: string, ): string | null { + if (managedDenial !== undefined) return managedDenial; if (!state || (state.enabled && state.status !== "disabled")) return null; return state.message?.trim() || "Automatic updates are not available in this build."; } export function getDesktopUpdateIdleTooltip( state: Pick | null, + managedDenial?: string, ) { - const disabledReason = getDesktopUpdateDisabledReason(state); + const disabledReason = getDesktopUpdateDisabledReason(state, managedDenial); return disabledReason ? { title: "Updates unavailable", detail: disabledReason } : { title: "Check for updates", detail: null }; diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index fc5a1aa7b91f9..98b34fbc00f89 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2468,6 +2468,7 @@ async function workspacePdfPreview() { [original, updated, shorter, shrunk, single].forEach(URL.revokeObjectURL), }; })(); + const threadId = ThreadId.make(urls.original); const preview = (src: string, mutation: string, path = "/repo/planning.pdf") => { assetUrlState.mockReturnValue({ _tag: "Success", @@ -2478,7 +2479,7 @@ async function workspacePdfPreview() {
import("./ReadOnlySourcePreview")); const STALE_URL_MS = 5 * 60_000; +const blobKeys = new WeakMap(); +let nextBlobKey = 0; + +function blobKey(blob: Blob) { + const key = blobKeys.get(blob) ?? `blob:${nextBlobKey++}`; + blobKeys.set(blob, key); + return key; +} + export function ReadOnlySourcePreview(props: { name: string; text: string; wordWrap?: boolean }) { return ( }> @@ -325,6 +334,17 @@ export function AttachmentFilePreview(props: { pdf={kind === "pdf"} htmlRender={props.htmlRender === true} threadRef={props.threadRef} + // Each mount signs or creates a new URL, so the URL cannot identify the file across visits. + // A draft keeps its Blob key after upload, so finishing the upload does not reload the PDF. + documentKey={ + kind !== "pdf" + ? undefined + : props.file + ? blobKey(props.file) + : props.asset && "attachmentId" in props.asset + ? `${props.asset.environmentId}:${props.asset.attachmentId}` + : undefined + } /> ) : kind === "audio" ? ( setError("Unable to load audio.")} /> diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 9d8afbc08a576..7c4aa4ad38e97 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -82,6 +82,7 @@ function PdfDocument(props: { src: string; title: string; documentKey: string }) key={props.documentKey} src={props.src} title={props.title} + positionKey={props.documentKey} initialView={view} onViewChange={setView} /> @@ -92,6 +93,7 @@ function PdfDocument(props: { src: string; title: string; documentKey: string }) function PdfDocumentRevision(props: { src: string; title: string; + positionKey: string; initialView: PdfViewState; onViewChange: (view: PdfViewState) => void; }) { @@ -115,6 +117,7 @@ function PdfDocumentRevision(props: { diff --git a/apps/web/src/components/files/PdfViewer.browser.test.tsx b/apps/web/src/components/files/PdfViewer.browser.test.tsx index 95100d12d3640..dfffa2c7378eb 100644 --- a/apps/web/src/components/files/PdfViewer.browser.test.tsx +++ b/apps/web/src/components/files/PdfViewer.browser.test.tsx @@ -109,3 +109,54 @@ it("fits the width, then finds split and rotated text and jumps to its page", as URL.revokeObjectURL(url); } }); + +it("restores page, within-page offset and zoom after a remount", async () => { + const url = URL.createObjectURL(twoPagePdf()); + const viewer = (mount: number, positionKey: string) => ( +
+ +
+ ); + try { + await using screen = await render(viewer(1, "restore:a")); + await expect.element(screen.getByText("1 / 2 pages")).toBeVisible(); + await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await expect.element(screen.getByText("2 / 2 pages")).toBeVisible(); + const region = () => screen.getByRole("region", { name: "Restored PDF" }).element(); + const top = region().scrollTop + 40; + const scrolled = new Promise((resolve) => + region().addEventListener("scroll", resolve, { once: true }), + ); + region().scrollTop = top; + await scrolled; + await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(top, 0)); + const zoom = screen.container.textContent!.match(/\d+%/)![0]; + + await screen.rerender(viewer(2, "restore:a")); + await expect.element(screen.getByText("2 / 2 pages")).toBeVisible(); + await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(top, 0)); + await vi.waitFor(() => expect(screen.container.textContent).toContain(zoom)); + + await screen.getByRole("button", { name: "Fit width", exact: true }).click(); + await vi.waitFor(() => { + const canvas = region().querySelector("canvas")!; + expect(canvas.getBoundingClientRect().width).toBe(region().clientWidth - 32); + }); + const fitTop = region().scrollTop; + await screen.rerender(viewer(3, "restore:a")); + await expect.element(screen.getByText("2 / 2 pages")).toBeVisible(); + await vi.waitFor(() => { + expect(region().querySelector("canvas")!.getBoundingClientRect().width).toBe( + region().clientWidth - 32, + ); + expect(region().scrollTop).toBeCloseTo(fitTop, 0); + }); + + await screen.rerender(viewer(4, "restore:b")); + await expect.element(screen.getByText("1 / 2 pages")).toBeVisible(); + await expect.element(screen.getByText("Fit width")).toBeVisible(); + } finally { + URL.revokeObjectURL(url); + } +}); diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index 502116a4b2696..d5fddf8a69003 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -31,6 +31,11 @@ import "./pdfViewer.css"; const MAX_PAGES = 2000; const MAX_FIND_TEXT = 4 * 1024 * 1024; +const MAX_POSITIONS = 50; +type PdfPosition = PdfViewState & { width: number; ratios: readonly number[] }; +// Thread switches unmount the preview; retain only the most recent documents. +const positions = new Map(); + export type PdfViewState = { page: number; offset: number; @@ -126,16 +131,20 @@ function PdfPage(props: { export default function PdfViewer(props: { src: string; title: string; + positionKey?: string; initialView?: PdfViewState; onViewChange?: (view: PdfViewState) => void; }) { + const [savedPosition] = useState(() => + props.positionKey === undefined ? undefined : positions.get(props.positionKey), + ); const [initialView] = useState( - () => props.initialView ?? { page: 0, offset: 0, zoom: null, left: 0 }, + () => savedPosition ?? props.initialView ?? { page: 0, offset: 0, zoom: null, left: 0 }, ); const [pdf, setPdf] = useState(null); const [error, setError] = useState(null); const [revision, setRevision] = useState(0); - const [ratios, setRatios] = useState([]); + const [ratios, setRatios] = useState([]); const [zoom, setZoom] = useState(initialView.zoom); const [pageWidth, setPageWidth] = useState(612); const [viewport, setViewport] = useState({ width: 600, height: 800 }); @@ -181,10 +190,12 @@ export default function PdfViewer(props: { if (controller.signal.aborted) return; setPageWidth(first.width); setRatios( - Array.from( - { length: document.numPages }, - (_, index) => sizes[index] ?? first.height / first.width, - ), + savedPosition?.ratios.length === document.numPages + ? savedPosition.ratios + : Array.from( + { length: document.numPages }, + (_, index) => sizes[index] ?? first.height / first.width, + ), ); setPdf(document); }) @@ -199,7 +210,7 @@ export default function PdfViewer(props: { clearTimeout(timeout); void task?.destroy(); }; - }, [props.src, revision, initialPage]); + }, [props.src, revision, initialPage, savedPosition]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure the PDF viewport before paint and observe resizes for fit-width sizing and visible-page selection. useLayoutEffect(() => { const node = scroll.current; @@ -222,14 +233,22 @@ export default function PdfViewer(props: { const jumpToPage = useEffectEvent((index: number) => { scroll.current?.scrollTo({ top: pdfPageScrollTop(offsets, index) }); }); - const saveView = (node: HTMLDivElement) => { + const saveView = (node: HTMLDivElement, notify = true) => { const page = currentPdfPage(offsets, ratios.length, node.scrollTop); - props.onViewChange?.({ + if (!pdf) return; + const view = { page, offset: Math.max(0, node.scrollTop - (offsets[page]! - 16)), zoom, left: node.scrollLeft, - }); + }; + if (notify) props.onViewChange?.(view); + if (props.positionKey !== undefined) { + positions.delete(props.positionKey); + positions.set(props.positionKey, { ...view, width, ratios }); + const oldest = positions.keys().next(); + if (positions.size > MAX_POSITIONS && !oldest.done) positions.delete(oldest.value); + } }; const restoreView = useEffectEvent(() => { const node = scroll.current; @@ -237,9 +256,15 @@ export default function PdfViewer(props: { const page = Math.min(initialPage, pdf.numPages - 1); node.scrollTo({ top: - offsets[page]! - 16 + Math.min(initialView.offset, Math.max(0, width * ratios[page]! - 1)), + offsets[page]! - + 16 + + Math.min( + initialView.offset * (savedPosition ? width / savedPosition.width : 1), + Math.max(0, width * ratios[page]! - 1), + ), left: initialView.left, }); + setScrollTop(node.scrollTop); saveView(node); }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restore the saved PDF scroll position after the loaded document's page layout commits. @@ -262,13 +287,13 @@ export default function PdfViewer(props: { const anchor = zoomAnchor.current; zoomAnchor.current = null; const node = scroll.current; - if (!anchor || !node) return; - if (anchor.width !== width) { + if (!node || !pdf) return; + if (anchor && anchor.width !== width) { node.scrollTo({ top: offsets[anchor.page]! - 16 + (anchor.offset * width) / anchor.width, }); } - saveView(node); + saveView(node, anchor !== null); }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Scroll to the first asynchronous search result after its measured page offsets have committed. useLayoutEffect(() => { diff --git a/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx b/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx index 735448ef496d8..046563bcb22f4 100644 --- a/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx +++ b/apps/web/src/components/files/useDirectoryEntries.browser.test.tsx @@ -1,66 +1,216 @@ import { RegistryContext } from "@effect/atom-react"; -import { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; import { AsyncResult, Atom } from "effect/reactivity"; -import { describe, expect, it, vi } from "vite-plus/test"; - import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; +import { useEffect } from "react"; import { act, render } from "../../../test/render"; -import { useDirectoryEntries } from "./useDirectoryEntries"; +import { expect, it, vi } from "vite-plus/test"; -const mocks = vi.hoisted(() => ({ - reachable: null as unknown as Atom.Writable, - connectedSession: null as unknown as Atom.Writable, +const { listEntries, mocks } = vi.hoisted(() => ({ + listEntries: vi.fn(), + mocks: { + reconnect: false, + reachable: null as unknown as Atom.Writable, + connectedSession: null as unknown as Atom.Writable, + }, })); - vi.mock("~/connection/catalog", () => ({ - // Derived like the real one, which only follows the connection once something computes it. environmentCatalog: { sessionGenerationAtom: () => Atom.make((get) => get(mocks.connectedSession)), }, })); vi.mock("~/state/projects", () => ({ projectEnvironment: { - listEntries: () => - Atom.make((get) => - get(mocks.reachable) - ? AsyncResult.success({ - entries: [{ path: "README.md", kind: "file" }], - truncated: false, - }) - : AsyncResult.failure(Cause.fail(new Error("Environment is reconnecting."))), - ), + listEntries: (request: unknown) => + mocks.reconnect + ? Atom.make((get) => + get(mocks.reachable) + ? AsyncResult.success({ + entries: [{ path: "README.md", kind: "file" }], + truncated: false, + }) + : AsyncResult.failure(Cause.fail(new Error("Environment is reconnecting."))), + ) + : Atom.make(Effect.promise(() => listEntries(request))), }, })); +import { useDirectoryEntries } from "./useDirectoryEntries"; + function setup() { + mocks.reconnect = false; mocks.reachable = Atom.make(false); mocks.connectedSession = Atom.make(1); + let view!: ReturnType; + const calls: Array<{ path: string; resolve: (entries: readonly ProjectEntry[]) => void }> = []; + let activeQueries = 0; + let peakQueries = 0; + function HookProbe() { + const result = useDirectoryEntries(EnvironmentId.make("files-test"), "/workspace"); + useEffect(() => { + view = result; + }); + return null; + } + + async function settle(index: number, entries: readonly ProjectEntry[] = []) { + await act(async () => calls[index]!.resolve(entries)); + } + + listEntries.mockReset().mockImplementation((request) => { + activeQueries++; + peakQueries = Math.max(peakQueries, activeQueries); + return new Promise((resolve) => { + calls.push({ + path: request.input.directoryPath, + resolve: (entries) => { + activeQueries--; + resolve({ entries }); + }, + }); + }); + }); + return { + Probe: () => ( + + + + ), + settle, + calls, + get view() { + return view; + }, + get activeQueries() { + return activeQueries; + }, + resetPeakQueries() { + peakQueries = activeQueries; + }, + get peakQueries() { + return peakQueries; + }, + }; } -function Probe() { +it("coalesces 20 overlapping refreshes", async () => { + const state = setup(); + // The hook probe does not navigate. + await using _screen = await render(, { router: false }); + await state.settle(0); + await act(async () => { + for (let index = 0; index < 20; index++) state.view.refresh(); + }); + for (let index = 1; index < state.calls.length; index++) await state.settle(index); + expect(state.calls).toHaveLength(3); // Initial load, active refresh, one final rescan. + expect(state.view.isPending).toBe(false); +}); + +it("rescans mutations during both the initial listing and the follow-up", async () => { + const state = setup(); + // The hook probe does not navigate. + await using _screen = await render(, { router: false }); + await act(async () => state.view.refresh()); + await state.settle(0); + expect(state.calls).toHaveLength(2); + await act(async () => { + state.view.refresh(); + state.view.refresh(); + }); + await state.settle(1); + expect(state.calls).toHaveLength(3); + const entry = { path: "latest.txt", kind: "file" } as const; + await state.settle(2, [entry]); + expect(state.view.entries).toEqual([entry]); + expect(state.view.isPending).toBe(false); +}); + +it("shares the follow-up promise and does not resolve callers before the final listing", async () => { + const state = setup(); + // The hook probe does not navigate. + await using _screen = await render(, { router: false }); + let completed = false; + const first = state.view.load("", true); + const second = state.view.load("", true); + expect(second).toBe(first); + void first.then(() => { + completed = true; + }); + await state.settle(0); + expect(completed).toBe(false); + await state.settle(1); + expect(completed).toBe(true); +}); + +it("retains the four-query concurrency bound across directories and refreshes", async () => { + const folders = Array.from({ length: 8 }, (_, index) => ({ + path: `folder-${index}`, + kind: "directory" as const, + })); + const state = setup(); + // The hook probe does not navigate. + await using _screen = await render(, { router: false }); + await state.settle(0, folders); + await act(async () => { + for (const folder of folders) void state.view.load(folder.path); + }); + for (let index = 1; index < state.calls.length; index++) { + await state.settle(index); + } + expect(state.activeQueries).toBe(0); + const refreshStart = state.calls.length; + state.resetPeakQueries(); + await act(async () => state.view.refresh()); + expect(state.peakQueries).toBe(4); + expect(state.activeQueries).toBe(4); + await act(async () => { + for (let index = 0; index < 20; index++) state.view.refresh(); + }); + for (let index = refreshStart; index < state.calls.length; index++) { + await state.settle(index, state.calls[index]!.path === "" ? folders : []); + expect(state.activeQueries).toBeLessThanOrEqual(4); + } + expect(state.peakQueries).toBe(4); + expect(new Set(state.calls.map((call) => call.path)).size).toBe(9); + for (const path of ["", ...folders.map((folder) => folder.path)]) { + expect(state.calls.filter((call) => call.path === path).length).toBeLessThanOrEqual(3); + } + expect(state.view.isPending).toBe(false); +}); + +it("does not start a queued follow-up after unmount", async () => { + const state = setup(); + // The hook probe does not navigate. + await using screen = await render(, { router: false }); + await act(async () => state.view.refresh()); + await screen.unmount(); + await state.settle(0); + expect(state.calls).toHaveLength(1); +}); + +function ReconnectProbe() { const directory = useDirectoryEntries(EnvironmentId.make("env-files"), "/repo"); return ( {directory.error ?? directory.entries.map((entry) => entry.path).join(",")} ); } -describe("useDirectoryEntries", () => { - it("reads a folder that failed while unreachable again once a new session is up", async () => { - setup(); - await using screen = await render( - - - , - // A bare hook probe; nothing here navigates. - { router: false }, - ); - await expect.element(screen.getByText("Environment is reconnecting.")).toBeInTheDocument(); - - await act(() => { - appAtomRegistry.set(mocks.reachable, true); - appAtomRegistry.set(mocks.connectedSession, 2); - }); - await expect.element(screen.getByText("README.md")).toBeInTheDocument(); +it("reads a folder that failed while unreachable again once a new session is up", async () => { + setup(); + mocks.reconnect = true; + await using screen = await render( + + + , + // The hook probe does not navigate. + { router: false }, + ); + await expect.element(screen.getByText("Environment is reconnecting.")).toBeInTheDocument(); + await act(() => { + appAtomRegistry.set(mocks.reachable, true); + appAtomRegistry.set(mocks.connectedSession, 2); }); + await expect.element(screen.getByText("README.md")).toBeInTheDocument(); }); diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index d9517aad79191..32aa2fe466753 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -16,6 +16,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { () => new Set([""]), ); const requests = useRef(new Map>()); + const followUpRefreshes = useRef(new Map>()); const loaded = useRef(new Set()); const requested = useRef(new Set()); const active = useRef(true); @@ -86,8 +87,18 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const load = function loadDirectory(directoryPath: string, refresh = false): Promise { const existing = requests.current.get(directoryPath); - if (existing) - return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing; + if (existing) { + if (!refresh) return existing; + let followUp = followUpRefreshes.current.get(directoryPath); + if (!followUp) { + followUp = existing.then(() => { + followUpRefreshes.current.delete(directoryPath); + if (active.current) return loadDirectory(directoryPath, true); + }); + followUpRefreshes.current.set(directoryPath, followUp); + } + return followUp; + } if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve(); setPending((count) => count + 1); if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath)); @@ -128,15 +139,10 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const refresh = () => { const paths = [...requested.current].filter((path) => reachableDirectories.has(path)); - const workerCount = Math.min(4, paths.length); - const worker = async () => { - while (active.current) { - const path = paths.shift(); - if (path === undefined) return; - await load(path, true); - } - }; - for (let index = 0; index < workerCount; index++) void worker(); + for (const path of paths) { + if (!active.current) break; + void load(path, true); + } }; return { diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index 5f4bc3fbb69e7..c55691d9ca5cc 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -22,6 +22,7 @@ type CreateProjectInput = { }; const state = vi.hoisted(() => ({ selectedEnvironment: "primary", + addedEnvironments: [] as string[], sessions: new Map(), projects: null as unknown, providers: null as unknown, @@ -67,7 +68,9 @@ vi.mock(import("../../state/environments"), async (original) => { return { ...(await original()), usePrimaryEnvironment: (() => primary) as never, - useEnvironments: (() => ({ environments: [environment(state.selectedEnvironment)] })) as never, + useEnvironments: (() => ({ + environments: [state.selectedEnvironment, ...state.addedEnvironments].map(environment), + })) as never, }; }); vi.mock(import("../../state/server"), async (original) => { @@ -232,6 +235,12 @@ async function click(label: string) { await act(async () => target.click()); } +async function clickStage(label: string) { + const stage = document.querySelector(`button[aria-label="${label}"]`); + expect(stage?.disabled).toBe(false); + await act(async () => stage?.click()); +} + async function mountImport(remote = false) { state.selectedEnvironment = remote ? "remote" : "primary"; await using stack = new AsyncDisposableStack(); @@ -253,6 +262,7 @@ async function mountImport(remote = false) { // Returns the clock spy so each test can `using` it; the spy would otherwise outlive the test. function setup() { const now = vi.spyOn(Date, "now").mockReturnValue(NOW); + state.addedEnvironments = []; state.sessions.clear(); for (const id of [primaryId, remoteId]) { state.sessions.set( @@ -282,6 +292,34 @@ function setup() { return now; } +it("keeps the project selection after going back a stage", async () => { + using _now = setup(); + await using _screen = await mountImport(); + await click("Select none"); + expect(pageText()).toContain("0 of 2 selected"); + + await clickStage("Agents, step 2"); + await click("Continue"); + expect(pageText()).toContain("Import your projects"); + expect(pageText()).toContain("0 of 2 selected"); +}); + +it("ticks the recent projects of a computer added after the selection changed", async () => { + using _now = setup(); + await using _screen = await mountImport(); + await click("Select none"); + + state.addedEnvironments = ["remote"]; + await clickStage("Connect, step 1"); + await click("Continue"); + await click("Continue"); + expect(pageText()).toContain("2 of 4 selected"); + + await click("Import 2 projects"); + const created = state.createProject.mock.calls as Array<[CreateProjectInput]>; + expect(created.map(([call]) => call.environmentId)).toEqual([remoteId, remoteId]); +}); + it("rechecks after project creation and can retry its history without recreating the project", async () => { using _now = setup(); const creation = deferred(); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 9a0b8461dee85..94694f73a465c 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -20,6 +20,7 @@ import { CommandId, defaultInstanceIdForDriver, AuthOrchestrationOperateScope, + isManagedDriverAllowed, ProviderDriverKind, ThreadId, } from "@t3tools/contracts"; @@ -60,6 +61,11 @@ import { newProjectId, randomUUID } from "../../lib/utils"; import { agentSessionImport } from "../../state/agentSessions"; import { readProjects, waitForProject } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { + managedInstanceLocks, + managedTelemetryOffMessage, + usePrimaryManagedPolicy, +} from "../../state/managedPolicy"; import { isOnboardingRelayEnvironment, resolveOnboardingSetup, @@ -134,6 +140,7 @@ export function WelcomeWizard({ const [setupIds, setSetupIds] = useState( resumeEnvironmentId ? [resumeEnvironmentId] : [], ); + const [importSelection, setImportSelection] = useState(null); const [isImporting, setIsImporting] = useState(false); const finishingPromiseRef = useRef | null>(null); const completionErrorToastIdRef = useRef | null>(null); @@ -291,6 +298,8 @@ export function WelcomeWizard({ ) : ( void; }) { const { environments } = useEnvironments(); + const telemetryOff = managedTelemetryOffMessage(usePrimaryManagedPolicy()); const cloudEnabled = hasCloudPublicConfig(); const directEnvironments = environments.filter( (environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment), @@ -446,8 +456,9 @@ function ConnectionStep({

{/* fork: brand */} - Loom sends no usage data unless you set T3CODE_TELEMETRY_ENABLED=true. If you do, it goes - to T3 under their{" "} + {telemetryOff === undefined + ? "Loom sends no usage data unless you set T3CODE_TELEMETRY_ENABLED=true. If you do, it goes to T3 under their" + : `${telemetryOff} To read more about how your data is used, see T3's`}{" "} { + const primaryAgents = PRIMARY_AGENT_DRIVERS.filter((driver) => + isManagedDriverAllowed(managedPolicy, ProviderDriverKind.make(driver)), + ).flatMap((driver) => { const instances = driver === "codex" ? providers?.filter((provider) => provider.driver === driver) : undefined; return instances?.length @@ -849,7 +868,8 @@ function ConnectedAgentsStep({ ), )}

- {providers?.some( + {chatGptAllowed && + providers?.some( (provider) => provider.driver === "codex" && getOnboardingProviderState(provider) === "ready", ) ? ( @@ -1191,13 +1211,23 @@ function AgentInstallTerminal({ const IMPORT_PERMISSION_MESSAGE = "This connection cannot import projects or thread history."; +interface ImportSelection { + // Computers whose projects were listed when the selection changed; later ones get the defaults. + readonly environmentIds: ReadonlySet; + readonly keys: ReadonlySet; +} + function ImportStep({ scans, + importSelection, + onImportSelectionChange, isImporting, setIsImporting, onDone, }: { readonly scans: ReturnType; + readonly importSelection: ImportSelection | null; + readonly onImportSelectionChange: (next: ImportSelection) => void; readonly isImporting: boolean; readonly setIsImporting: (value: boolean) => void; readonly onDone: ( @@ -1210,7 +1240,6 @@ function ImportStep({ const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); - const [selectedPaths, setSelectedPaths] = useState | null>(null); const importWarningRef = useRef(""); const importedThreadCountRef = useRef(0); const importedProjectsRef = useRef(new Map()); @@ -1238,8 +1267,20 @@ function ImportStep({ })), ), ); - const selectedKeys = selectedPaths ?? new Set(recent.map((candidate) => candidate.key)); - const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key)); + const recentKeys = new Set(recent.map((candidate) => candidate.key)); + const selected = candidates.filter((candidate) => + importSelection?.environmentIds.has(candidate.environmentId) + ? importSelection.keys.has(candidate.key) + : recentKeys.has(candidate.key), + ); + const selectedKeys = new Set(selected.map((candidate) => candidate.key)); + const setSelectedKeys = (keys: ReadonlySet) => + onImportSelectionChange({ + environmentIds: new Set( + scans.flatMap((scan) => (scan.data === null ? [] : [scan.environmentId])), + ), + keys, + }); const canImport = selected.every((candidate) => writableEnvironments.has(candidate.environmentId), @@ -1449,7 +1490,7 @@ function ImportStep({ variant="ghost" size="xs" disabled={isImporting || selected.length === candidates.length} - onClick={() => setSelectedPaths(new Set(candidates.map((item) => item.key)))} + onClick={() => setSelectedKeys(new Set(candidates.map((item) => item.key)))} > Select all @@ -1457,7 +1498,7 @@ function ImportStep({ variant="ghost" size="xs" disabled={isImporting || selected.length === 0} - onClick={() => setSelectedPaths(new Set())} + onClick={() => setSelectedKeys(new Set())} > Select none @@ -1510,7 +1551,7 @@ function ImportStep({ ); diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 0d75a1311fb87..f66183c9fcb12 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -107,18 +107,21 @@ const availableArea = ( PREVIEW_MINI_PLAYER_EDGE_GAP * 2, }); +const minimumPreviewMiniPlayerWidth = (source: PreviewMiniPlayerSize) => + Math.max( + PREVIEW_MINI_PLAYER_MIN_SIZE.width, + (PREVIEW_MINI_PLAYER_MIN_SIZE.height * source.width) / source.height, + ); + function fitPreviewMiniPlayerWidth( desiredWidth: number, source: PreviewMiniPlayerSize, max: PreviewMiniPlayerSize, + minimumWidth = minimumPreviewMiniPlayerWidth(source), ): PreviewMiniPlayerSize { const aspectRatio = source.width / source.height; const width = Math.min( - Math.max( - desiredWidth, - PREVIEW_MINI_PLAYER_MIN_SIZE.width, - PREVIEW_MINI_PLAYER_MIN_SIZE.height * aspectRatio, - ), + Math.max(desiredWidth, minimumWidth), Math.max(1, max.width), Math.max(1, max.height * aspectRatio), ); @@ -244,6 +247,7 @@ export function resolvePreviewMiniPlayerFrame(input: { position && width ? { composer: obstacles.composer, detailsCard: null } : obstacles, position && width ? spanOf(position.x, width) : null, ), + width ?? undefined, ); const { detailsCard } = obstacles; const anchored = @@ -307,7 +311,12 @@ export function resizePreviewMiniPlayer(input: { : horizontal ? desiredWidth : desiredHeight * aspectRatio; - const size = fitPreviewMiniPlayerWidth(desired, source, max); + const size = fitPreviewMiniPlayerWidth( + desired, + source, + max, + Math.min(start.width, minimumPreviewMiniPlayerWidth(source)), + ); const position = clampPreviewMiniPlayerPosition( { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y }, container, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 86e2e27b98365..8246fad432b43 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -204,7 +204,7 @@ function FileViewedToggle({ {stat}
- {wizardStep === 0 ? ( + {wizardStep === 0 && (moreDriverOptions.length > 0 || acpRegistryAllowed) ? (
}> @@ -491,18 +509,20 @@ export function AddProviderInstanceDialog({
-
-
- Or choose from ACP Registry -
-
- {isAcpRegistry && isManualAcpConfiguration ? ( + {!acpRegistryAllowed ? null : ( +
+
+ Or choose from ACP Registry +
+
+ )} + {!acpRegistryAllowed ? null : isAcpRegistry && isManualAcpConfiguration ? (
@@ -684,6 +704,9 @@ export function AddProviderInstanceDialog({ idPrefix={`add-provider-${driver}`} variant="settings" onChange={setConfigDraft} + managed={ + driverLocks && { keys: driverLocks.configKeys, reason: driverLocks.reason } + } environmentId={environmentId} /> @@ -716,10 +739,12 @@ export function AddProviderInstanceDialog({ - setAddingChatGptAccount(true)} - /> + {driverLocks?.configKeys.has("setupMode") ? null : ( + setAddingChatGptAccount(true)} + /> + )} ) : wizardStep < (isAcpRegistry ? 1 : 2) ? ( + {pairingDenial !== undefined ? ( + + + + ) : null} { setDialogOpen(open); if (!open) { @@ -646,14 +658,16 @@ export const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHead } }} > - - - Create link - - } - /> + {pairingDenial === undefined ? ( + + + Create link + + } + /> + ) : null} Create pairing link @@ -856,6 +870,7 @@ export const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow const canDisableTailscaleServe = isTailscaleHttpsEndpoint(endpoint) && endpoint.status === "available"; const shouldShowEndpointUrl = !needsTailscaleSetup; + const remoteDenial = managedCapabilityDenial(usePrimaryManagedPolicy(), "remoteClients"); return (
{/* fork: no-edge-accent - the Default badge marks this row, not an edge rail. */} @@ -889,14 +904,16 @@ export const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow ) : null} {needsTailscaleSetup ? ( - + + + ) : null} {canDisableTailscaleServe ? (
} + managed={tailscaleHttpsEndpoint?.status === "available" ? undefined : remoteClientsDenial} /> ); const renderAuthorizedClients = () => ( @@ -995,6 +1001,7 @@ export function ConnectionsSettings({ view }: { readonly view: "environments" | ) : null } control={renderNetworkAccessToggle()} + managed={isLocalBackendNetworkAccessible ? undefined : remoteClientsDenial} /> ); const renderDisabledNetworkAccessRow = () => ( @@ -1077,6 +1084,11 @@ export function ConnectionsSettings({ view }: { readonly view: "environments" | ) : null } > + {primaryManagedPolicy ? ( +
+ +
+ ) : null} {canManageLocalBackend ? ( { expect(importFailureReason(failure("profileLimitReached"))).toBe("profileLimitReached"); }); }); + +describe("importPolicyRefusal", () => { + it("recovers a managed policy's refusal from the IPC-wrapped message", () => { + const denied = managedPolicyDeniedMessage("Example Corp", "importing browser cookies"); + expect( + importPolicyRefusal({ + message: `Error invoking remote method 'desktop:preview-import-cookies': BrowserImportPolicyError: ${denied}`, + }), + ).toBe(denied); + const unreadable = + "The managed policy at C:\\Program Files\\T3Code\\policy.json could not be read, so importing browser cookies is not allowed."; + expect(importPolicyRefusal({ message: `Error: ${unreadable}` })).toBe(unreadable); + }); + + it("leaves other failures to their reason token", () => { + expect(importPolicyRefusal(failure("readFailed"))).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index f3909560ddc32..70ca16ad085e2 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -35,7 +35,9 @@ import { type BrowserImportSource, type PreviewAppearancePreference, type PreviewViewportSetting, + managedCapabilityDenial, } from "@t3tools/contracts"; +import { usePrimaryManagedPolicy } from "~/state/managedPolicy"; import { isValidSearchUrlTemplate } from "@t3tools/shared/preview"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; import { MoreVertical, Plus as PlusIcon } from "lucide-react"; @@ -188,6 +190,18 @@ export const importFailureReason = (cause: unknown): BrowserImportFailureReason ); }; +/** + * The desktop's refusal when the machine's managed policy forbids cookie import. IPC wraps the + * message in its own prefixes, so the sentence is matched on the contract's wording. + */ +export const importPolicyRefusal = (cause: unknown): string | undefined => { + const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); + return ( + /The managed policy at .* importing browser cookies is not allowed\./u.exec(message)?.[0] ?? + /(?:^|: )([^:]*'s policy does not allow importing browser cookies\.)/u.exec(message)?.[1] + ); +}; + async function importBrowserCookiesIntoTarget( bridge: NonNullable, source: BrowserImportSource, @@ -262,7 +276,10 @@ async function importBrowserCookiesIntoTarget( targetName, }; } catch (cause) { - return { kind: "blocked", reason: importFailureReason(cause) }; + const policyRefusal = importPolicyRefusal(cause); + return policyRefusal !== undefined + ? { kind: "blocked", reason: "readFailed", message: policyRefusal } + : { kind: "blocked", reason: importFailureReason(cause) }; } } @@ -1093,6 +1110,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const updateSettings = useUpdatePrimarySettings(); const { environments, isReady: environmentsReady } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + // Cookie import runs on this desktop, whose policy the primary environment reports. + const cookieImportDenial = managedCapabilityDenial( + usePrimaryManagedPolicy(), + "browserCookieImport", + ); const [sources, setSources] = useState | null>(null); const [importSession, setImportSession] = useState<{ readonly source: BrowserImportSource; @@ -1300,7 +1322,9 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { Import from - {sources === null ? ( + {cookieImportDenial !== undefined ? ( + {cookieImportDenial} + ) : sources === null ? ( Looking for browsers… ) : importableSources.length === 0 ? ( No supported browsers found diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 751ff6ad04d2f..7a94fcb5432f6 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -1,5 +1,6 @@ import { useAuth } from "@clerk/react"; import { Link } from "@tanstack/react-router"; +import { managedCapabilityDenial } from "@t3tools/contracts"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; @@ -10,6 +11,8 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL import { searchableSetting } from "./settingsSearch"; import { LegacyFeaturesSection } from "./SettingsPanels"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { useOptionalSettingsScope } from "./SettingsScopeContext"; +import { firstManagedDenial, usePrimaryManagedPolicy } from "~/state/managedPolicy"; /** Features still settling in, and legacy behavior kept for people who rely on it. */ export function LabsSettingsPanel() { @@ -91,11 +94,22 @@ function GuestLinksRowWithSignIn() { function GuestLinksRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); + const scope = useOptionalSettingsScope(); + const primaryPolicy = usePrimaryManagedPolicy(); + // Guests arrive through T3 Connect, so a policy that turns Connect off turns this off too. + const connectDenial = firstManagedDenial( + scope + ? scope.connectedEnvironments.map((environment) => environment.serverConfig?.managedPolicy) + : [primaryPolicy], + (policy) => managedCapabilityDenial(policy, "connect"), + ); return ( Share a thread with a guest who watches it live in their browser and sends messages you diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 3d6ad58d1d957..d81b7ad1120ad 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, type ModelSelection, type WorktreeSubmodules, + managedRuntimeModeDenial, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -19,6 +20,7 @@ import { resolveProjectDefaultProviderModelSelection, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; +import { firstManagedDenial } from "../../state/managedPolicy"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; @@ -380,15 +382,26 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting {runtimeModeOptions.map((mode) => { const option = runtimeModeConfig[mode]; const Icon = option.icon; + const managedReason = firstManagedDenial( + connectedEnvironments.map( + (environment) => environment.serverConfig?.managedPolicy, + ), + (policy) => managedRuntimeModeDenial(policy, mode), + ); return ( - +
{option.label} - {option.description} + {managedReason ?? option.description}
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 557cf735e5e6a..6214f399bcc60 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -120,6 +120,12 @@ vi.mock(import("../ui/toast"), async (original) => { // These sections have separate data sources and their own tests. vi.mock(import("./ProjectFolderLocation"), () => ({ ProjectFolderLocation: () => <> })); // fork: project-home vi.mock(import("../../fork/settings/pages"), () => ({ ForkSettingsTopics: () => <> })); // fork: ext-settings +// fork: library-projects +vi.mock(import("../../fork/project-library/FolderProjectSettings"), () => ({ + FolderProjectSettings: ({ children }) => ( + <>{typeof children === "function" ? children(null) : children} + ), +})); vi.mock(import("./ProjectDefaultsSettings"), async (original) => ({ ...(await original()), ProjectDefaultsSettings: (() => null) as never, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index ef63d5b3d2d9a..d04f372a2c778 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -51,6 +51,9 @@ import { VaultProviderCredential } from "~/fork/vault/ProviderCredential"; import { isVaultProviderReference } from "@t3tools/contracts/fork"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; +import { ManagedControl, ManagedNotice } from "../ManagedPolicy"; +import { managedInstanceLocks, useManagedPolicy } from "../../state/managedPolicy"; +import { disallowedProviderMessage, isManagedDriverAllowed } from "@t3tools/contracts"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { @@ -281,6 +284,7 @@ function ProviderEnvironmentFieldRow(props: { secret: SecretReference | undefined, ) => void; readonly onRemove: (field: ProviderEnvironmentField) => void; + readonly managedReason?: string | undefined; readonly secretSources: ReadonlyArray; // fork: vault-provider-credentials readonly vaultContext?: { environmentId: EnvironmentId; providerName: string } | undefined; @@ -315,6 +319,7 @@ function ProviderEnvironmentFieldRow(props: { ) } description={props.field.description} + managed={props.managedReason} control={
{props.field.sensitive === false ? ( @@ -361,6 +366,8 @@ function ProviderEnvironmentFieldRow(props: { export function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; + /** Variables an administrator's policy pins: listed by name, never editable or removable. */ + readonly managed?: { readonly names: ReadonlySet; readonly reason: string } | undefined; readonly secretSources?: ReadonlyArray; // fork: vault-provider-credentials readonly vaultContext?: { environmentId: EnvironmentId; providerName: string } | undefined; @@ -402,6 +409,8 @@ export function ProviderEnvironmentSection(props: { }; const updateVariable = (id: string, patch: Partial>) => { + // A row named like a pinned variable renders as the policy's, with no way to edit or remove it. + if (patch.name !== undefined && props.managed?.names.has(patch.name)) return; const nextRows = rows.map((row) => row.id === id ? { @@ -456,6 +465,16 @@ export function ProviderEnvironmentSection(props: { {rows.length > 0 ? (
{rows.map((variable, index) => { + if (props.managed?.names.has(variable.name)) { + return ( +
+ + {variable.name} + + {props.managed.reason} +
+ ); + } const nameInput = ( ({ onCopy: ({ providerName }) => { toastManager.add({ @@ -1155,12 +1182,15 @@ export function ProviderInstanceCard({ /> ) : null} - updateEnabled(Boolean(checked))} - aria-label={`Enable ${displayName}`} - /> + {/* The policy refuses switching it on; switching it off stays the user's call. */} + + updateEnabled(Boolean(checked))} + aria-label={`Enable ${displayName}`} + /> +
); @@ -1242,6 +1272,7 @@ export function ProviderInstanceCard({ idPrefix={`provider-instance-${instanceId}`} variant="settings" onChange={updateConfig} + managed={instanceLocks && { keys: instanceLocks.configKeys, reason: instanceLocks.reason }} environmentId={environmentId} /> ) : ( @@ -1309,6 +1340,12 @@ export function ProviderInstanceCard({ onCommit={updateEnvironmentField} onSecretChange={updateEnvironmentFieldSecret} onRemove={removeEnvironmentField} + managedReason={ + instanceLocks?.wholeEnvironment || + instanceLocks?.environmentNames.has(field.name) + ? instanceLocks.reason + : undefined + } secretSources={secretSources} // fork: vault-provider-credentials vaultContext={ @@ -1349,17 +1386,28 @@ export function ProviderInstanceCard({ - + {instanceLocks?.wholeEnvironment ? ( + {instanceLocks.reason} + ) : null} +
+ +
{environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? ( ) { + managedReason, +}: Pick & { + readonly managedReason: string | undefined; +}) { const args = configuredCommandArgs(value); const [storedDraft, setDraft] = useState(() => seedRowsDraft(args, toCommandArgumentRows)); const draft = reconcileRowsDraft(storedDraft, args, commandArgumentsEqual, toCommandArgumentRows); @@ -226,6 +229,7 @@ function ProviderCommandArguments({ {rows.length > 0 ? (
- {rows.map((argument, index) => ( -
- - updateArguments( - rows.map((current) => - current.id === argument.id ? { ...current, value: next } : current, - ), - ) - } - aria-label={`Argument ${index + 1}`} - spellCheck={false} - /> - -
- ))} + {rows.map((argument, index) => + managedReason !== undefined ? ( + + {argument.value} + + ) : ( +
+ + updateArguments( + rows.map((current) => + current.id === argument.id ? { ...current, value: next } : current, + ), + ) + } + aria-label={`Argument ${index + 1}`} + spellCheck={false} + /> + +
+ ), + )}
) : null}
@@ -325,6 +335,8 @@ interface ProviderSettingsFormProps { readonly idPrefix: string; readonly variant: "card" | "dialog" | "settings"; readonly onChange: (nextConfig: Record | undefined) => void; + /** Config keys an administrator's policy pins, shown inert with the reason. */ + readonly managed?: { readonly keys: ReadonlySet; readonly reason: string } | undefined; /** Where the secret providers offered for `SettingsSecretValue` fields are configured. */ readonly environmentId?: EnvironmentId | undefined; } @@ -446,6 +458,7 @@ interface ProviderSettingsFieldRowProps { readonly idPrefix: string; readonly variant: ProviderSettingsFormProps["variant"]; readonly onChange: ProviderSettingsFormProps["onChange"]; + readonly managedReason?: string | undefined; readonly environmentId: EnvironmentId | undefined; } @@ -455,6 +468,7 @@ function ProviderSettingsFieldRow({ idPrefix, variant, onChange, + managedReason, environmentId, }: ProviderSettingsFieldRowProps) { const inputId = `${idPrefix}-${field.key}`; @@ -576,6 +590,7 @@ function ProviderSettingsFieldRow({ field.description ? {field.description} : undefined } control={control} + managed={managedReason} /> ); } @@ -684,6 +699,7 @@ export function ProviderSettingsForm({ idPrefix, variant, onChange, + managed, environmentId, }: ProviderSettingsFormProps) { const fields = deriveProviderSettingsFields(definition, value); @@ -705,10 +721,17 @@ export function ProviderSettingsForm({ idPrefix={idPrefix} variant={variant} onChange={onChange} + managedReason={managed?.keys.has(field.key) ? managed.reason : undefined} environmentId={environmentId} /> ))} - {isLocalAcp ? : null} + {isLocalAcp ? ( + + ) : null} ); } diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index b5fb648ff97c2..e0cdea5432161 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -96,6 +96,7 @@ import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { useManagedPolicy } from "~/state/managedPolicy"; import { ExpandableText } from "./ExpandableText"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { useSecretSources } from "./SecretSourceInput"; @@ -548,6 +549,10 @@ export function EnvironmentProviderSettings({ readonly readOnly?: boolean; }) { const settings = useEnvironmentSettings(environmentId); + const managedPolicy = useManagedPolicy(environmentId); + // A policy that allows no provider leaves nothing to add. + const canAddProvider = + !readOnly && managedPolicy?.limits?.providers?.allowedDrivers?.length !== 0; const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); const updateSettings = useUpdateEnvironmentSettings(environmentId); @@ -962,7 +967,7 @@ export function EnvironmentProviderSettings({ {...searchableSetting("providers")} variant="plain" titleAction={ - !readOnly ? ( + canAddProvider ? (
{rows.map((row) => renderProviderInstance(row, "list"))} - {!readOnly ? ( + {canAddProvider ? (
- {draft.scheduleMode === "webhook" ? ( + {webhookDenial !== undefined ? ( + + {draft.scheduleMode === "webhook" + ? `${webhookDenial} Switch this task to a time or an interval, or turn it off, to save it.` + : webhookDenial} + + ) : null} + {/* A disabled task still saves, so keep its fields reachable for fixing. */} + {draft.scheduleMode === "webhook" && + webhookDenial !== undefined && + draft.enabled ? null : draft.scheduleMode === "webhook" ? (

@@ -1962,7 +1989,14 @@ function ScheduledTaskEditorForm({